import importlib.util import json from pathlib import Path from unittest.mock import patch, MagicMock _SKILL_ROOT = Path(__file__).resolve().parent.parent def _load_cli(): spec = importlib.util.spec_from_file_location( "collect_findings", _SKILL_ROOT / "scripts" / "collect-findings.py" ) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def _fake_subprocess(cmd, **kwargs): if cmd[:2] == ["git", "-C"]: sub = cmd[2:] else: sub = cmd if "symbolic-ref" in sub: return MagicMock(returncode=0, stdout="refs/remotes/origin/main\n", stderr="") if "fetch" in sub: return MagicMock(returncode=0, stdout="", stderr="") if "rev-parse" in sub and "--verify" in sub: return MagicMock(returncode=0, stdout="abc\n", stderr="") if cmd[:1] == ["git"] and "diff" in cmd: diff = ( "diff --git a/src/api.py b/src/api.py\n" "index 1..2 100644\n" "--- a/src/api.py\n" "+++ b/src/api.py\n" "@@ -12 +12,1 @@\n" "+x = parse(user_input)\n" ) return MagicMock(returncode=0, stdout=diff, stderr="") if Path(cmd[0]).name == "bandit": out = json.dumps({"results": [{ "filename": "src/api.py", "line_number": 12, "line_range": [12, 12], "issue_severity": "HIGH", "issue_text": "Use of dynamic parsing", "test_id": "B307", }]}) return MagicMock(returncode=0, stdout=out, stderr="") return MagicMock(returncode=0, stdout="", stderr="") def test_cli_aborts_when_no_supported_files(tmp_path): repo = tmp_path / "repo" repo.mkdir() out_dir = tmp_path / "out" mod = _load_cli() def _fake(cmd, **kw): if cmd[:1] == ["git"] and "diff" in cmd: return MagicMock(returncode=0, stdout="diff --git a/README.md b/README.md\nindex 1..2 100644\n--- a/README.md\n+++ b/README.md\n@@ -1 +1,1 @@\n+x\n", stderr="") return _fake_subprocess(cmd, **kw) with patch("subprocess.run", side_effect=_fake): with patch("shutil.which", return_value=None): rc = mod.main([ "--repo", str(repo), "--head", "HEAD", "--output-dir", str(out_dir), "--mode", "local", ]) assert rc == 1 manifest = json.loads((out_dir / "manifest.json").read_text()) assert any("no supported source files" in e.lower() for e in manifest["errors"]) def test_cli_happy_path_python_only(tmp_path): from scripts import runner repo = tmp_path / "repo" repo.mkdir() (repo / "src").mkdir() (repo / "src" / "api.py").write_text("x = 1\n") out_dir = tmp_path / "out" mod = _load_cli() empty_venv = tmp_path / "empty-venv" empty_venv.mkdir() def _which(binary): return "/usr/bin/bandit" if binary == "bandit" else None with patch.object(runner, "_SKILL_VENV_BIN", empty_venv): with patch("subprocess.run", side_effect=_fake_subprocess): with patch("shutil.which", side_effect=_which): rc = mod.main([ "--repo", str(repo), "--head", "HEAD", "--output-dir", str(out_dir), "--mode", "local", ]) assert rc == 0 manifest = json.loads((out_dir / "manifest.json").read_text()) assert manifest["mode"] == "local" assert manifest["language_breakdown"]["python"] == 1 rule_ids = {f["rule_id"] for f in manifest["findings"]} assert "B307" in rule_ids for agent in ("security-triage", "type-safety", "dependency", "consistency", "secrets"): assert (out_dir / f"manifest-{agent}.json").exists() def test_cli_records_tool_unavailable(tmp_path): from scripts import runner repo = tmp_path / "repo" repo.mkdir() (repo / "src").mkdir() (repo / "src" / "api.py").write_text("x = 1\n") out_dir = tmp_path / "out" mod = _load_cli() empty_venv = tmp_path / "empty-venv" empty_venv.mkdir() with patch.object(runner, "_SKILL_VENV_BIN", empty_venv): with patch("subprocess.run", side_effect=_fake_subprocess): with patch("shutil.which", return_value=None): rc = mod.main([ "--repo", str(repo), "--head", "HEAD", "--output-dir", str(out_dir), "--mode", "local", ]) assert rc == 0 manifest = json.loads((out_dir / "manifest.json").read_text()) assert "bandit" in manifest["tools_unavailable"] assert manifest["tools_unavailable"]["bandit"].endswith("scripts/install-tools.sh --user-only") def test_cli_alerts_on_unsupported_languages(tmp_path): """Mixed PR: Python supported, Go file alerts — but review still proceeds.""" repo = tmp_path / "repo" repo.mkdir() (repo / "src").mkdir() (repo / "src" / "api.py").write_text("x = 1\n") out_dir = tmp_path / "out" mod = _load_cli() def _fake(cmd, **kw): if cmd[:1] == ["git"] and "diff" in cmd: diff = ( "diff --git a/src/api.py b/src/api.py\n" "index 1..2 100644\n--- a/src/api.py\n+++ b/src/api.py\n" "@@ -1 +1,1 @@\n+x = 1\n" "diff --git a/cmd/server.go b/cmd/server.go\n" "index 3..4 100644\n--- a/cmd/server.go\n+++ b/cmd/server.go\n" "@@ -1 +1,1 @@\n+package main\n" ) return MagicMock(returncode=0, stdout=diff, stderr="") return _fake_subprocess(cmd, **kw) with patch("subprocess.run", side_effect=_fake): with patch("shutil.which", return_value=None): rc = mod.main([ "--repo", str(repo), "--head", "HEAD", "--output-dir", str(out_dir), "--mode", "local", ]) assert rc == 0, "review should continue when at least one supported file present" manifest = json.loads((out_dir / "manifest.json").read_text()) assert any("Go" in e and "server.go" in e for e in manifest["errors"]), manifest["errors"] assert manifest["language_breakdown"]["python"] == 1 def test_cli_alerts_on_unsupported_only_and_aborts(tmp_path): """Diff contains ONLY unsupported languages: alert + abort.""" repo = tmp_path / "repo" repo.mkdir() out_dir = tmp_path / "out" mod = _load_cli() def _fake(cmd, **kw): if cmd[:1] == ["git"] and "diff" in cmd: diff = ( "diff --git a/cmd/server.go b/cmd/server.go\n" "index 3..4 100644\n--- a/cmd/server.go\n+++ b/cmd/server.go\n" "@@ -1 +1,1 @@\n+package main\n" ) return MagicMock(returncode=0, stdout=diff, stderr="") return _fake_subprocess(cmd, **kw) with patch("subprocess.run", side_effect=_fake): with patch("shutil.which", return_value=None): rc = mod.main([ "--repo", str(repo), "--head", "HEAD", "--output-dir", str(out_dir), "--mode", "local", ]) assert rc == 1 manifest = json.loads((out_dir / "manifest.json").read_text()) errors_blob = " | ".join(manifest["errors"]) assert "Go" in errors_blob, errors_blob assert "no supported source files" in errors_blob.lower()