From b00a6b9eef21c4b3f388e6c6306b1219dfcf803f Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 6 May 2026 18:37:46 -0400 Subject: [PATCH 1/2] feat(sp-ruff-checks): plain and auto output format Auto-detects some common agent harnesses. Will run in plain mode if rich is not installed. Assisted-by: Copilot:GPT-5.4-mini Assisted-by: OpenCode:Kimi-K2.6 Signed-off-by: Henry Schreiner --- pyproject.toml | 2 + src/sp_repo_review/ruff_checks/__main__.py | 241 ++++++++++++++++----- tests/test_ruff_checks_cli.py | 42 ++++ 3 files changed, 235 insertions(+), 50 deletions(-) create mode 100644 tests/test_ruff_checks_cli.py diff --git a/pyproject.toml b/pyproject.toml index 43157882..d2956232 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -165,6 +165,7 @@ messages_control.disable = [ "redefined-outer-name", "no-member", # better handled by mypy, etc. "arguments-differ", # better handled by mypy, etc. + "import-outside-toplevel", # in Ruff ] @@ -196,6 +197,7 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "src/sp_repo_review/_compat/**.py" = ["TID251"] "src/sp_repo_review/checks/*.py" = ["ERA001"] +"src/sp_repo_review/ruff_checks/__main__.py" = ["PLC0415", "T20"] "tests/**" = ["ANN", "INP001", "S607"] "helpers/**" = ["INP001", "FIX004"] "helpers/extensions.py" = ["ANN"] diff --git a/src/sp_repo_review/ruff_checks/__main__.py b/src/sp_repo_review/ruff_checks/__main__.py index d08c28fd..aadc9673 100644 --- a/src/sp_repo_review/ruff_checks/__main__.py +++ b/src/sp_repo_review/ruff_checks/__main__.py @@ -2,27 +2,25 @@ "argparse", "collections", "collections.abc", + "os", "pathlib", - "rich", - "rich.columns", - "rich.panel", "sp_repo_review._compat", "sp_repo_review.checks", "sp_repo_review.checks.ruff", "sys", + "typing", ] import argparse +import importlib import importlib.resources import json +import os import sys from collections.abc import Iterator, Mapping +from importlib.util import find_spec from pathlib import Path -from rich import print -from rich.columns import Columns -from rich.panel import Panel - from sp_repo_review._compat import tomllib from sp_repo_review.checks.ruff import get_rule_selection, ruff @@ -43,14 +41,172 @@ IGNORE_INFO = json.load(f) -def print_each(items: Mapping[str, str]) -> Iterator[str]: +def _is_agent_environment() -> bool: + """Check if running from an AI coding agent. + + Uses environment variables from https://github.com/agentsmd/agents.md/issues/136 + """ + + # Tool-specific agent variables + agent_vars = [ + "AGENT", # Pi, Goose, Amp + "CLAUDECODE", + "CURSOR_AGENT", + "CLINE_ACTIVE", + "GEMINI_CLI", + "CODEX_SANDBOX", + "AUGMENT_AGENT", + "TRAE_AI_SHELL_ID", + "OPENCODE_CLIENT", + ] + + return any(os.environ.get(var) for var in agent_vars) + + +def _resolve_format(format_arg: str) -> str: + """Resolve 'auto' format to either 'rich' or 'plain'.""" + if format_arg != "auto": + return format_arg + + if _is_agent_environment(): + return "plain" + + return "rich" if _has_rich() else "plain" + + +def _has_rich() -> bool: + return find_spec("rich") is not None + + +def _print_each_plain(items: Mapping[str, str], indent: int = 2) -> Iterator[str]: + """Generate plain text formatted rule lines.""" + size = max(len(k) for k in items) if items else 0 + for k, v in items.items(): + yield f"{' ' * indent}\"{k}\",{' ' * (size - len(k))} # {v}" + + +def _print_each_rich(items: Mapping[str, str]) -> Iterator[str]: + """Generate rich formatted rule lines.""" size = max(len(k) for k in items) if items else 0 for k, v in items.items(): kk = f'[green]"{k}"[/green],' yield f" {kk:{size + 18}} [dim]# {v}[/dim]" -def process_dir(path: Path) -> None: +def _output_error(fmt: str, message: str) -> None: + """Output error message in appropriate format.""" + if fmt == "rich": + import rich + + rich.print(message, file=sys.stderr) + else: + print(message, file=sys.stderr) + + +def _print_output_rich( + selected_items: dict[str, str], + libs_items: dict[str, str], + spec_items: dict[str, str], + unselected_items: dict[str, str], +) -> None: + """Print rich formatted output.""" + import rich.columns + import rich.panel + + panel_sel = rich.panel.Panel( + "\n".join(_print_each_rich(selected_items)), + title="Selected", + border_style="green", + ) + panel_lib = rich.panel.Panel( + "\n".join(_print_each_rich(libs_items)), + title="Library specific", + border_style="yellow", + ) + panel_spec = rich.panel.Panel( + "\n".join(_print_each_rich(spec_items)), + title="Specialized", + border_style="yellow", + ) + uns = "\n".join(_print_each_rich(unselected_items)) + + rich.print(rich.columns.Columns([panel_sel, panel_lib, panel_spec])) + if uns: + rich.print("[red]Unselected [dim](copy and paste ready)") + rich.print(uns) + + +def _print_output_plain( + selected_items: dict[str, str], + libs_items: dict[str, str], + spec_items: dict[str, str], + unselected_items: dict[str, str], +) -> None: + """Print plain formatted output.""" + print("Selected:") + for item in _print_each_plain(selected_items): + print(item) + + if libs_items: + print("\nLibrary specific:") + for item in _print_each_plain(libs_items): + print(item) + + if spec_items: + print("\nSpecialized:") + for item in _print_each_plain(spec_items): + print(item) + + if unselected_items: + print("\nUnselected (copy and paste ready):") + for item in _print_each_plain(unselected_items): + print(item) + + +def _handle_all_selected(fmt: str, ruff_config: dict[str, object]) -> None: + """Handle the case when ALL rules are selected.""" + ignored = get_rule_selection(ruff_config, "ignore") + missed = [ + r + for r in IGNORE_INFO + if not any( + x.startswith((r.get("rule", "."), r.get("family", "."))) + for x in (ignored or []) + ) + ] + + msg = '[green]"ALL"[/green] selected.' if fmt == "rich" else '"ALL" selected.' + if fmt == "rich": + import rich + + rich.print(msg) + else: + print(msg) + + ignores = {v.get("rule", v.get("family", "")): v["reason"] for v in missed} + if ignores: + msg_header = "Some things that sometimes need ignoring:" + if fmt == "rich": + import rich + + rich.print(msg_header) + for item in _print_each_rich(ignores): + rich.print(item) + else: + print(msg_header) + for item in _print_each_plain(ignores): + print(item) + + +def process_dir(path: Path, format: str = "auto") -> None: + """Process a directory and display ruff rules configuration. + + Args: + path: Directory to process + format: Output format - 'auto', 'rich', or 'plain' + """ + fmt = _resolve_format(format) + try: with path.joinpath("pyproject.toml").open("rb") as f: pyproject = tomllib.load(f) @@ -58,37 +214,29 @@ def process_dir(path: Path) -> None: pyproject = {} ruff_config = ruff(pyproject=pyproject, root=path) + if fmt == "rich" and not _has_rich(): + _output_error( + "plain", "Error: --format rich requested, but rich is not installed" + ) + raise SystemExit(3) + if ruff_config is None: - print( - "[red]Could not find a ruff config [dim](.ruff.toml, ruff.toml, or pyproject.toml)", - file=sys.stderr, + msg = ( + "[red]Could not find a ruff config [dim](.ruff.toml, ruff.toml, or pyproject.toml)" + if fmt == "rich" + else "Error: Could not find a ruff config (.ruff.toml, ruff.toml, or pyproject.toml)" ) + _output_error(fmt, msg) raise SystemExit(1) + selected = get_rule_selection(ruff_config) if not selected: - print( - "[red]No rules selected", - file=sys.stderr, - ) + msg = "[red]No rules selected" if fmt == "rich" else "Error: No rules selected" + _output_error(fmt, msg) raise SystemExit(2) if "ALL" in selected: - ignored = get_rule_selection(ruff_config, "ignore") - missed = [ - r - for r in IGNORE_INFO - if not any( - x.startswith((r.get("rule", "."), r.get("family", "."))) - for x in ignored - ) - ] - - print('[green]"ALL"[/green] selected.') - ignores = {v.get("rule", v.get("family", "")): v["reason"] for v in missed} - if ignores: - print("Some things that sometimes need ignoring:") - for item in print_each(ignores): - print(item) + _handle_all_selected(fmt, ruff_config) return selected_items = {k: v for k, v in LINT_INFO.items() if k in selected} @@ -99,23 +247,10 @@ def process_dir(path: Path) -> None: libs_items = {k: v for k, v in all_uns_items.items() if k in LIBS} spec_items = {k: v for k, v in all_uns_items.items() if k in SPECIALTY} - panel_sel = Panel( - "\n".join(print_each(selected_items)), title="Selected", border_style="green" - ) - panel_lib = Panel( - "\n".join(print_each(libs_items)), - title="Library specific", - border_style="yellow", - ) - panel_spec = Panel( - "\n".join(print_each(spec_items)), title="Specialized", border_style="yellow" - ) - uns = "\n".join(print_each(unselected_items)) - - print(Columns([panel_sel, panel_lib, panel_spec])) - if uns: - print("[red]Unselected [dim](copy and paste ready)") - print(uns) + if fmt == "rich": + _print_output_rich(selected_items, libs_items, spec_items, unselected_items) + else: + _print_output_plain(selected_items, libs_items, spec_items, unselected_items) def main() -> None: @@ -127,9 +262,15 @@ def main() -> None: default=Path.cwd(), help="Directory to process (default: current working directory)", ) + parser.add_argument( + "--format", + choices=["auto", "rich", "plain"], + default="auto", + help="Output format (default: auto)", + ) args = parser.parse_args() - process_dir(args.path) + process_dir(args.path, format=args.format) if __name__ == "__main__": diff --git a/tests/test_ruff_checks_cli.py b/tests/test_ruff_checks_cli.py new file mode 100644 index 00000000..fec23e9a --- /dev/null +++ b/tests/test_ruff_checks_cli.py @@ -0,0 +1,42 @@ +import sys +from importlib.util import find_spec as _find_spec + +from sp_repo_review.ruff_checks import __main__ as ruff_checks + + +def test_auto_and_plain_do_not_require_rich(monkeypatch, tmp_path, capsys): + def no_rich_find_spec(name, package=None): + if name == "rich" or name.startswith("rich."): + return None + return _find_spec(name, package=package) + + monkeypatch.setattr(ruff_checks, "ruff", lambda *_a, **_k: {"tool": "ruff"}) + monkeypatch.setattr(ruff_checks, "get_rule_selection", lambda *_a, **_k: {"A"}) + monkeypatch.setattr(ruff_checks, "LINT_INFO", {"A": "Rule A"}) + monkeypatch.setattr(ruff_checks, "LIBS", frozenset()) + monkeypatch.setattr(ruff_checks, "SPECIALTY", frozenset()) + monkeypatch.setattr(ruff_checks, "_is_agent_environment", lambda: False) + monkeypatch.setattr(ruff_checks, "find_spec", no_rich_find_spec) + + for mod in list(sys.modules): + if mod == "rich" or mod.startswith("rich."): + monkeypatch.delitem(sys.modules, mod, raising=False) + + for fmt in ("plain", "auto"): + ruff_checks.process_dir(tmp_path, format=fmt) + captured = capsys.readouterr() + assert "Selected:" in captured.out + assert captured.err == "" + + +def test_plain_format_has_quotes_and_comma(monkeypatch, tmp_path, capsys): + """Regression test: plain format should quote rules for copy-paste.""" + monkeypatch.setattr(ruff_checks, "ruff", lambda *_a, **_k: {"tool": "ruff"}) + monkeypatch.setattr(ruff_checks, "get_rule_selection", lambda *_a, **_k: {"A"}) + monkeypatch.setattr(ruff_checks, "LINT_INFO", {"A": "Rule A"}) + monkeypatch.setattr(ruff_checks, "LIBS", frozenset()) + monkeypatch.setattr(ruff_checks, "SPECIALTY", frozenset()) + + ruff_checks.process_dir(tmp_path, format="plain") + captured = capsys.readouterr() + assert '"A",' in captured.out From 5557db40de23c5be64085e7736fe1b65bbbd263d Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 6 May 2026 20:03:28 -0400 Subject: [PATCH 2/2] chore: clean up a littl Signed-off-by: Henry Schreiner --- src/sp_repo_review/ruff_checks/__main__.py | 37 ++++++++++------------ 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/src/sp_repo_review/ruff_checks/__main__.py b/src/sp_repo_review/ruff_checks/__main__.py index aadc9673..c883f11b 100644 --- a/src/sp_repo_review/ruff_checks/__main__.py +++ b/src/sp_repo_review/ruff_checks/__main__.py @@ -40,27 +40,24 @@ with RESOURCE_DIR.joinpath("ignore.json").open(encoding="utf-8") as f: IGNORE_INFO = json.load(f) +# Tool-specific agent variables +# Based on https://github.com/agentsmd/agents.md/issues/136 +_AGENT_VARS = [ + "AGENT", # Pi, Goose, Amp + "CLAUDECODE", + "CURSOR_AGENT", + "CLINE_ACTIVE", + "GEMINI_CLI", + "CODEX_SANDBOX", + "AUGMENT_AGENT", + "TRAE_AI_SHELL_ID", + "OPENCODE_CLIENT", +] -def _is_agent_environment() -> bool: - """Check if running from an AI coding agent. - - Uses environment variables from https://github.com/agentsmd/agents.md/issues/136 - """ - - # Tool-specific agent variables - agent_vars = [ - "AGENT", # Pi, Goose, Amp - "CLAUDECODE", - "CURSOR_AGENT", - "CLINE_ACTIVE", - "GEMINI_CLI", - "CODEX_SANDBOX", - "AUGMENT_AGENT", - "TRAE_AI_SHELL_ID", - "OPENCODE_CLIENT", - ] - return any(os.environ.get(var) for var in agent_vars) +def _is_agent_environment() -> bool: + """Check if running from an AI coding agent using env vars.""" + return any(os.environ.get(var) for var in _AGENT_VARS) def _resolve_format(format_arg: str) -> str: @@ -82,7 +79,7 @@ def _print_each_plain(items: Mapping[str, str], indent: int = 2) -> Iterator[str """Generate plain text formatted rule lines.""" size = max(len(k) for k in items) if items else 0 for k, v in items.items(): - yield f"{' ' * indent}\"{k}\",{' ' * (size - len(k))} # {v}" + yield f'{" " * indent}"{k}",{" " * (size - len(k))} # {v}' def _print_each_rich(items: Mapping[str, str]) -> Iterator[str]: