diff --git a/src/skillspector/cli.py b/src/skillspector/cli.py index f6b4f85..123c8c8 100644 --- a/src/skillspector/cli.py +++ b/src/skillspector/cli.py @@ -146,15 +146,20 @@ def _scan_state( return state +def _result_body(result: dict) -> str: + report_body = result.get("report_body") or "" + if not report_body and result.get("sarif_report") is not None: + report_body = json.dumps(result["sarif_report"], indent=2) + return report_body + + def _write_result( result: dict[str, object], output: Path | None, format: FormatChoice, ) -> None: """Write report_body to file or stdout. Uses sarif_report if report_body missing.""" - report_body = result.get("report_body") or "" - if not report_body and result.get("sarif_report") is not None: - report_body = json.dumps(result["sarif_report"], indent=2) + report_body = _result_body(result) if output: Path(output).write_text(report_body, encoding="utf-8") if format == FormatChoice.terminal: @@ -432,9 +437,13 @@ def _scan_multi_skill( Path(output).write_text(json.dumps(combined, indent=2), encoding="utf-8") console.print(f"[green]Combined report saved to:[/green] {output}") elif output: - for _skill, result in zip(skills, results, strict=True): + # concatenated non-JSON output: not merged SARIF + sections = [] + for skill, result in zip(skills, results, strict=True): if "error" not in result: - _write_result(result, None, format) + sections.append(f"--- {skill.relative_path} ---\n\n{_result_body(result)}") + Path(output).write_text("\n\n".join(sections), encoding="utf-8") + console.print(f"[green]Combined report saved to:[/green] {output}") if max_score > 50: raise typer.Exit(code=1) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index b8c8823..2d9e1bf 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -17,10 +17,13 @@ import json from pathlib import Path +from unittest.mock import patch +import pytest from typer.testing import CliRunner -from skillspector.cli import app +from skillspector.cli import FormatChoice, _scan_multi_skill, app +from skillspector.multi_skill import MultiSkillDetectionResult, SkillDirectory runner = CliRunner() @@ -113,3 +116,76 @@ def test_cli_baseline_generate_then_scan_round_trip(tmp_path: Path) -> None: data = json.loads(scan.output) assert data["issues"] == [] assert data["risk_assessment"]["score"] == 0 + + +def test_scan_multi_skill_markdown_output_to_file( + tmp_path: Path, capsys: pytest.CaptureFixture +) -> None: + """Non-JSON recursive scan writes concatenated report to file, not stdout.""" + s1 = SkillDirectory(path=tmp_path / "skill1", name="skill1", relative_path="skill1") + s2 = SkillDirectory(path=tmp_path / "skill2", name="skill2", relative_path="skill2") + detection = MultiSkillDetectionResult( + is_multi_skill=True, skills=[s1, s2], has_root_skill=False + ) + + result1 = { + "report_body": "# Report ALPHA for skill1", + "risk_score": 10, + "risk_severity": "LOW", + "findings": [], + } + result2 = { + "report_body": "# Report BETA for skill2", + "risk_score": 10, + "risk_severity": "LOW", + "findings": [], + } + out = tmp_path / "report.md" + + with patch("skillspector.cli.graph.invoke", side_effect=[result1, result2]): + _scan_multi_skill( + detection, FormatChoice.markdown, out, no_llm=True, yara_rules_dir=None, verbose=False + ) + + assert out.exists() + text = out.read_text() + assert "ALPHA" in text + assert "BETA" in text + assert "---" in text + + captured = capsys.readouterr() + assert "ALPHA" not in captured.out + assert "BETA" not in captured.out + + +def test_scan_multi_skill_json_output_unchanged(tmp_path: Path) -> None: + """JSON recursive scan still produces a valid combined JSON file.""" + s1 = SkillDirectory(path=tmp_path / "skill1", name="skill1", relative_path="skill1") + s2 = SkillDirectory(path=tmp_path / "skill2", name="skill2", relative_path="skill2") + detection = MultiSkillDetectionResult( + is_multi_skill=True, skills=[s1, s2], has_root_skill=False + ) + + result1 = { + "report_body": "# Report ALPHA for skill1", + "risk_score": 10, + "risk_severity": "LOW", + "findings": [], + } + result2 = { + "report_body": "# Report BETA for skill2", + "risk_score": 10, + "risk_severity": "LOW", + "findings": [], + } + out = tmp_path / "combined.json" + + with patch("skillspector.cli.graph.invoke", side_effect=[result1, result2]): + _scan_multi_skill( + detection, FormatChoice.json, out, no_llm=True, yara_rules_dir=None, verbose=False + ) + + assert out.exists() + data = json.loads(out.read_text()) + assert data["multi_skill"] is True + assert "skills" in data