From ebbb77e33367141832bd23584e4eda6e05923cb3 Mon Sep 17 00:00:00 2001 From: qiaobochi040726-source Date: Thu, 6 Aug 2026 05:12:35 +0800 Subject: [PATCH] report: add per-tool grouping view via --group-by --- stellargate/cli.py | 10 +++++-- stellargate/report.py | 46 ++++++++++++++++++++++------- tests/test_aggregator_and_report.py | 41 +++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 12 deletions(-) diff --git a/stellargate/cli.py b/stellargate/cli.py index 1a6c532..1cc3dec 100644 --- a/stellargate/cli.py +++ b/stellargate/cli.py @@ -19,6 +19,12 @@ def main(argv: list[str] | None = None) -> int: run_parser.add_argument("--json-report", default=None, help="Write JSON report to this path") run_parser.add_argument("--md-report", default=None, help="Write Markdown report to this path") run_parser.add_argument("--fail-on", default=None, help="Override fail_on threshold from config") + run_parser.add_argument( + "--group-by", + default="severity", + choices=["severity", "tool"], + help="Group findings by severity or by tool (default: severity)", + ) args = parser.parse_args(argv) @@ -46,7 +52,7 @@ def _run(args: argparse.Namespace) -> int: findings = all_findings(results) passed = gate_passed(findings, fail_on) - print(to_markdown(results, fail_on, passed)) + print(to_markdown(results, fail_on, passed, args.group_by)) if args.json_report: with open(args.json_report, "w") as f: @@ -54,7 +60,7 @@ def _run(args: argparse.Namespace) -> int: if args.md_report: with open(args.md_report, "w") as f: - f.write(to_markdown(results, fail_on, passed)) + f.write(to_markdown(results, fail_on, passed, args.group_by)) return 0 if passed else 1 diff --git a/stellargate/report.py b/stellargate/report.py index e32127a..e1f9df8 100644 --- a/stellargate/report.py +++ b/stellargate/report.py @@ -30,7 +30,15 @@ def to_json(results: list[ToolRunResult], fail_on: str, gate_passed: bool) -> di } -def to_markdown(results: list[ToolRunResult], fail_on: str, gate_passed: bool) -> str: +def to_markdown( + results: list[ToolRunResult], + fail_on: str, + gate_passed: bool, + group_by: str = "severity", +) -> str: + if group_by not in ("severity", "tool"): + raise ValueError(f"Invalid group_by '{group_by}'; must be 'severity' or 'tool'") + findings = [f for r in results for f in r.findings] lines: list[str] = [] @@ -55,15 +63,33 @@ def to_markdown(results: list[ToolRunResult], fail_on: str, gate_passed: bool) - lines.append(f"- **{r.tool}**: {r.error}") lines.append("") - if findings: - lines.append("## Findings\n") - lines.append("| Severity | Tool | Rule | Location | Message |") - lines.append("|---|---|---|---|---|") - for f in findings: - loc = f.location or "—" - lines.append( - f"| {f.severity.upper()} | {f.tool} | {f.rule_id} | {loc} | {f.message} |" - ) + if findings or group_by == "tool": + if group_by == "severity": + lines.append("## Findings\n") + lines.append("| Severity | Tool | Rule | Location | Message |") + lines.append("|---|---|---|---|---|") + for f in findings: + loc = f.location or "—" + lines.append( + f"| {f.severity.upper()} | {f.tool} | {f.rule_id} | {loc} | {f.message} |" + ) + else: + lines.append("## Findings\n") + for r in results: + if r.error: + continue + lines.append(f"### {r.tool}\n") + if r.findings: + lines.append("| Severity | Rule | Location | Message |") + lines.append("|---|---|---|---|") + for f in sorted(r.findings, key=lambda x: -x.severity_rank): + loc = f.location or "—" + lines.append( + f"| {f.severity.upper()} | {f.rule_id} | {loc} | {f.message} |" + ) + else: + lines.append("No findings. Clean run.") + lines.append("") else: lines.append("No findings. Clean run.") diff --git a/tests/test_aggregator_and_report.py b/tests/test_aggregator_and_report.py index 842c5cf..6573ad3 100644 --- a/tests/test_aggregator_and_report.py +++ b/tests/test_aggregator_and_report.py @@ -1,5 +1,7 @@ from unittest.mock import patch +import pytest + from stellargate.aggregator import ToolRunResult, all_findings, run_all from stellargate.config import Config, ToolConfig from stellargate.report import gate_passed, to_json, to_markdown @@ -60,6 +62,45 @@ def test_to_markdown_shows_failed_status_and_tool_error(): assert "STELLAR-001" in md +def test_to_markdown_default_grouping_is_severity(): + results = make_results() + md_default = to_markdown(results, "high", False) + md_explicit = to_markdown(results, "high", False, group_by="severity") + # explicitly requesting severity must not change the default output + assert md_explicit == md_default + assert "| Severity | Tool | Rule | Location | Message |" in md_default + + +def test_to_markdown_groups_by_tool(): + results = make_results() + md = to_markdown(results, "high", False, group_by="tool") + assert "### rytscan" in md + assert "### vaultsweep" in md + # per-tool tables have no redundant Tool column + assert "| Severity | Tool | Rule | Location | Message |" not in md + assert "AUTH-001" in md + assert "STELLAR-001" in md + + +def test_to_markdown_tool_group_skips_errored_tools_and_marks_clean(): + results = make_results() + # schemalock errored -> not shown as a findings section (it is in Tool errors) + md = to_markdown(results, "high", False, group_by="tool") + assert "### schemalock" not in md + assert "schemalock CLI not found" in md + # a healthy tool with no findings is marked as clean + results.append(ToolRunResult("shieldscan", [], None)) + md2 = to_markdown(results, "high", False, group_by="tool") + assert "### shieldscan" in md2 + assert "No findings. Clean run." in md2 + + +def test_to_markdown_rejects_unknown_grouping(): + results = make_results() + with pytest.raises(ValueError, match="bogus"): + to_markdown(results, "high", False, group_by="bogus") + + def test_run_all_survives_an_unexpected_adapter_exception(): """Regression test: a bug in one adapter (e.g. a bare KeyError, not an AdapterError) must not crash the whole run — every other tool's findings