Files
code-review-graph/tests/test_skills.py
T
dev 84ae9b817e feat: add unified-review workflow (scoring tools + skill)
Adds the unified-review integration that fuses CRG graph context with the
ai-code-review scoring methodology and gstack-review fix-first workflow:

- scoring.py: objective Layer-2 metrics (sql_risk, exception_coverage,
  redundancy_rate, high_risk_density, vulnerability_risk) with
  good/warn/fail grades, plus dedupe_findings (fingerprint merge,
  multi-source confidence boost, PR quality score) and report data builder
- tools/scoring_tools.py + main.py: three new MCP tools
  (score_review_tool, dedupe_findings_tool, generate_report_tool)
- assets/report-template.html: self-contained HTML report template
- skills.py + skills/unified-review/: new read-only unified-review skill
  with language/manual-review/specialist checklists
- docs and CHANGELOG updated; tests added (test_scoring, test_report,
  test_unified_review) and test_skills updated for 5 skills
2026-08-05 13:31:55 +08:00

2487 lines
101 KiB
Python

"""Tests for skills and hooks auto-install."""
import json
import os
import stat
import subprocess
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
if sys.version_info >= (3, 11):
import tomllib
else: # pragma: no cover - Python 3.10 backport
import tomli as tomllib
from code_review_graph import skills as skills_module
from code_review_graph.skills import (
_CLAUDE_MD_SECTION_MARKER,
PLATFORMS,
_copilot_vscode_detected,
_cursor_hook_scripts,
_detect_serve_command,
_in_poetry_project,
_in_uv_project,
_opencode_plugin_content,
_strip_jsonc,
generate_codex_hooks_config,
generate_cursor_hooks_config,
generate_hooks_config,
generate_skills,
inject_claude_md,
inject_platform_instructions,
install_codex_hooks,
install_cursor_hooks,
install_gemini_cli_hooks,
install_gemini_cli_skills,
install_git_hook,
install_hooks,
install_opencode_plugin,
install_platform_configs,
)
_needs_tomllib = pytest.mark.skipif(
tomllib is None, reason="tomllib requires Python 3.11+",
)
class TestStripJsonc:
"""JSONC sanitizer must not corrupt string values (GH #553)."""
def test_comma_inside_string_preserved(self):
# The original #553 repro: a comma inside a string, immediately before a
# line whose first non-space char is `}`. A naive regex deleted it.
src = (
'{\n'
' "mcp": {\n'
' "my-server": {\n'
' "command": ["x"],\n'
' "description": "foo, bar"\n'
' }\n'
' }\n'
'}\n'
)
parsed = json.loads(_strip_jsonc(src))
assert parsed["mcp"]["my-server"]["description"] == "foo, bar"
def test_url_with_double_slash_preserved(self):
# The `//` comment stripper must not truncate `https://...` inside a string.
src = '{"url": "https://mcp.example.com/path", "n": 1}'
parsed = json.loads(_strip_jsonc(src))
assert parsed["url"] == "https://mcp.example.com/path"
assert parsed["n"] == 1
def test_real_trailing_comma_before_brace_removed(self):
src = '{"a": 1, "b": 2,}'
assert json.loads(_strip_jsonc(src)) == {"a": 1, "b": 2}
def test_real_trailing_comma_before_bracket_removed(self):
src = '{"list": [1, 2, 3,]}'
assert json.loads(_strip_jsonc(src)) == {"list": [1, 2, 3]}
def test_line_comment_removed(self):
src = '{\n "a": 1 // inline comment\n}'
assert json.loads(_strip_jsonc(src)) == {"a": 1}
def test_block_comment_removed(self):
src = '{\n /* leading */ "a": 1\n}'
assert json.loads(_strip_jsonc(src)) == {"a": 1}
def test_comment_markers_inside_string_preserved(self):
src = '{"a": "x // y", "b": "p /* q */ r"}'
parsed = json.loads(_strip_jsonc(src))
assert parsed["a"] == "x // y"
assert parsed["b"] == "p /* q */ r"
def test_escaped_quote_does_not_break_string_tracking(self):
# The escaped quote must not end the string early; the comma after it is
# data, and the `}` that follows is structural.
src = '{"a": "he said \\"hi, there\\"", "b": 2,}'
parsed = json.loads(_strip_jsonc(src))
assert parsed["a"] == 'he said "hi, there"'
assert parsed["b"] == 2
def test_trailing_comma_then_comment_then_close(self):
src = '{\n "a": 1, // trailing then comment\n}'
assert json.loads(_strip_jsonc(src)) == {"a": 1}
def test_strict_json_unchanged(self):
src = '{"a": [1, 2], "b": {"c": "d, e"}}'
assert json.loads(_strip_jsonc(src)) == json.loads(src)
class TestGenerateSkills:
def test_creates_skills_directory(self, tmp_path):
result = generate_skills(tmp_path)
assert result.is_dir()
assert result == tmp_path / ".claude" / "skills"
def test_creates_five_skill_subdirs(self, tmp_path):
skills_dir = generate_skills(tmp_path)
subdirs = sorted(f.name for f in skills_dir.iterdir() if f.is_dir())
assert subdirs == [
"debug-issue",
"explore-codebase",
"refactor-safely",
"review-changes",
"unified-review",
]
for d in skills_dir.iterdir():
assert (d / "SKILL.md").is_file()
def test_skill_files_have_frontmatter(self, tmp_path):
skills_dir = generate_skills(tmp_path)
for subdir in skills_dir.iterdir():
path = subdir / "SKILL.md"
content = path.read_text()
assert content.startswith("---\n")
assert "name:" in content
assert "description:" in content
# Frontmatter closes
lines = content.split("\n")
assert lines[0] == "---"
closing_idx = content.index("---", 4)
assert closing_idx > 0
def test_skill_frontmatter_names_match_lowercase_directories(self, tmp_path):
"""Generated and bundled skills use the discovery-safe name format."""
generated = generate_skills(tmp_path)
bundled = Path(__file__).parents[1] / "skills"
for skill_name in (
"debug-issue",
"explore-codebase",
"refactor-safely",
"review-changes",
"unified-review",
):
for skill_file in (
generated / skill_name / "SKILL.md",
bundled / skill_name / "SKILL.md",
):
content = skill_file.read_text(encoding="utf-8")
assert f"\nname: {skill_name}\n" in content
def test_custom_skills_dir(self, tmp_path):
custom = tmp_path / "my-skills"
result = generate_skills(tmp_path, skills_dir=custom)
assert result == custom
assert result.is_dir()
assert len(list(result.iterdir())) == 5
def test_skill_content_includes_get_minimal_context(self, tmp_path):
"""Every skill template must reference get_minimal_context."""
skills_dir = generate_skills(tmp_path)
for subdir in skills_dir.iterdir():
content = (subdir / "SKILL.md").read_text()
assert "get_minimal_context" in content, (
f"{subdir.name} missing get_minimal_context reference"
)
def test_skill_content_includes_detail_level(self, tmp_path):
"""Every skill template must reference detail_level."""
skills_dir = generate_skills(tmp_path)
for subdir in skills_dir.iterdir():
content = (subdir / "SKILL.md").read_text()
assert "detail_level" in content, (
f"{subdir.name} missing detail_level reference"
)
def test_idempotent(self, tmp_path):
"""Running twice should not fail and files should still be valid."""
generate_skills(tmp_path)
generate_skills(tmp_path)
skills_dir = tmp_path / ".claude" / "skills"
assert len(list(skills_dir.iterdir())) == 5
class TestGenerateHooksConfig:
def test_returns_dict_with_hooks(self):
config = generate_hooks_config(Path("/repo"))
assert "hooks" in config
def test_has_post_tool_use(self):
config = generate_hooks_config(Path("/repo"))
assert "PostToolUse" in config["hooks"]
entry = config["hooks"]["PostToolUse"][0]
assert entry["matcher"] == "Edit|Write"
inner = entry["hooks"][0]
assert inner["type"] == "command"
assert "update" in inner["command"]
assert inner["command"].startswith("cat >/dev/null || true; ")
assert 0 < inner["timeout"] <= 600
def test_has_session_start(self):
config = generate_hooks_config(Path("/repo"))
assert "SessionStart" in config["hooks"]
entry = config["hooks"]["SessionStart"][0]
assert "matcher" in entry
inner = entry["hooks"][0]
assert inner["type"] == "command"
assert "status" in inner["command"]
assert inner["command"].startswith("cat >/dev/null || true; ")
assert 0 < inner["timeout"] <= 600
def test_does_not_emit_invalid_pre_commit_hook(self):
config = generate_hooks_config(Path("/repo"))
assert "PreCommit" not in config["hooks"]
def test_has_only_valid_hook_types(self):
config = generate_hooks_config(Path("/repo"))
hook_types = set(config["hooks"].keys())
assert hook_types == {"PostToolUse", "SessionStart"}
def test_hook_entries_use_nested_hooks_array(self):
config = generate_hooks_config(Path("/repo"))
for hook_type, entries in config["hooks"].items():
for entry in entries:
assert "hooks" in entry, f"{hook_type} entry missing 'hooks' array"
assert "command" not in entry, f"{hook_type} has bare 'command' outside hooks[]"
def test_hooks_have_path_guard(self):
"""Regression test for #549: hooks must guard against missing binary."""
config = generate_hooks_config(Path("/repo"))
for hook_type, entries in config["hooks"].items():
for entry in entries:
for hook in entry["hooks"]:
assert "command -v code-review-graph" in hook["command"], (
f"{hook_type} hook missing PATH guard — will fail noisily"
" when binary is not on PATH (e.g. project venv)"
)
def test_hooks_use_dynamic_repo_root(self):
"""Regression test for #558: hooks must not embed absolute paths.
The repo root should be resolved at runtime via git rev-parse so
settings.json is shareable across collaborators.
"""
config = generate_hooks_config(Path("/my/specific/checkout/path"))
for hook_type, entries in config["hooks"].items():
for entry in entries:
for hook in entry["hooks"]:
assert "git rev-parse --show-toplevel" in hook["command"], (
f"{hook_type} hook should use git rev-parse --show-toplevel"
" to resolve repo root dynamically"
)
def test_hooks_no_absolute_path_embedded(self):
"""Regression test for #558: no absolute path should appear in commands."""
config = generate_hooks_config(Path("/home/user/projects/my-repo"))
for hook_type, entries in config["hooks"].items():
for entry in entries:
for hook in entry["hooks"]:
assert "/home/user/projects/my-repo" not in hook["command"], (
f"{hook_type} hook embeds absolute path — settings.json"
" is not shareable across collaborators"
)
def test_post_tool_use_matcher_excludes_bash(self):
"""Regression test for #549: Bash matcher fires on every shell command."""
config = generate_hooks_config(Path("/repo"))
matcher = config["hooks"]["PostToolUse"][0]["matcher"]
assert "Bash" not in matcher, (
"PostToolUse matcher includes Bash — fires on every shell command"
" (git status, ls, test runs), not just file mutations"
)
def test_entries_use_claude_code_hook_schema(self):
"""Regression guard for the Claude Code hook schema.
Claude Code rejects entries that put ``command`` directly on the
event entry. Each entry must wrap its command(s) in a
``hooks: [{"type": "command", "command": ..., "timeout": ...}]``
array — missing that wrapper causes the entire settings.json to
fail to parse ("Expected array, but received undefined").
"""
config = generate_hooks_config(Path("/repo"))
for event_name, entries in config["hooks"].items():
for entry in entries:
assert "command" not in entry, (
f"{event_name} entry has a flat `command` field; "
"it must be wrapped in an inner `hooks` array"
)
assert "hooks" in entry, (
f"{event_name} entry is missing the inner `hooks` array"
)
assert isinstance(entry["hooks"], list)
for hook in entry["hooks"]:
assert hook.get("type") == "command", (
f"{event_name} inner hook missing type=\"command\""
)
assert "command" in hook
assert "timeout" in hook
class TestShippedHooksFiles:
"""The vestigial hooks/ directory ships in the sdist (see pyproject
sdist includes). Its hook commands must drain stdin exactly like the
skills.py-generated hooks, or large hook payloads reproduce the
BrokenPipeError from bug #493.
"""
HOOKS_DIR = Path(__file__).resolve().parent.parent / "hooks"
STDIN_DRAIN = "cat >/dev/null || true; "
def test_hooks_json_commands_drain_stdin(self):
data = json.loads(
(self.HOOKS_DIR / "hooks.json").read_text(encoding="utf-8")
)
commands = [
hook["command"]
for entries in data.values()
for entry in entries
for hook in entry.get("hooks", [])
if hook.get("type") == "command"
]
assert commands, "hooks/hooks.json should define at least one command hook"
for command in commands:
assert command.startswith(self.STDIN_DRAIN), (
f"hooks.json command lacks the stdin drain prefix: {command!r}"
)
def test_session_start_script_drains_stdin(self):
script = (self.HOOKS_DIR / "session-start.sh").read_text(encoding="utf-8")
assert "cat >/dev/null" in script, (
"session-start.sh must drain stdin to avoid BrokenPipeError "
"on large hook payloads (bug #493)"
)
class TestInstallGitHook:
def _make_git_repo(self, tmp_path: Path) -> Path:
(tmp_path / ".git" / "hooks").mkdir(parents=True)
return tmp_path
def _git(self, *args: str, cwd: Path) -> str:
result = subprocess.run(
["git", *args],
cwd=str(cwd),
capture_output=True,
text=True,
encoding="utf-8",
stdin=subprocess.DEVNULL,
timeout=30,
check=True,
)
return result.stdout.strip()
def _init_real_repo(self, path: Path) -> Path:
path.mkdir(parents=True, exist_ok=True)
self._git("init", cwd=path)
return path
def test_creates_executable_pre_commit_hook(self, tmp_path):
hook_path = install_git_hook(self._make_git_repo(tmp_path))
assert hook_path is not None and hook_path.name == "pre-commit"
assert os.access(hook_path, os.X_OK)
content = hook_path.read_text()
assert content.startswith("#!/")
assert "code-review-graph detect-changes" in content
def test_appends_to_existing_hook(self, tmp_path):
repo = self._make_git_repo(tmp_path)
hook_path = repo / ".git" / "hooks" / "pre-commit"
hook_path.write_text("#!/bin/sh\nexisting-command\n", encoding="utf-8")
hook_path.chmod(0o755)
install_git_hook(repo)
content = hook_path.read_text()
assert "existing-command" in content
assert "code-review-graph detect-changes" in content
def test_idempotent(self, tmp_path):
repo = self._make_git_repo(tmp_path)
install_git_hook(repo)
install_git_hook(repo)
content = (repo / ".git" / "hooks" / "pre-commit").read_text()
assert content.count("code-review-graph detect-changes") == 1
def test_no_git_dir_returns_none(self, tmp_path):
assert install_git_hook(tmp_path) is None
def test_real_repo_installs_into_git_hooks(self, tmp_path):
"""Standard repo: unchanged behavior — hook lands in .git/hooks."""
repo = self._init_real_repo(tmp_path / "std")
hook_path = install_git_hook(repo)
assert hook_path is not None
expected = repo / ".git" / "hooks" / "pre-commit"
assert hook_path.resolve() == expected.resolve()
assert os.access(hook_path, os.X_OK)
assert "code-review-graph detect-changes" in hook_path.read_text()
def test_respects_core_hooks_path(self, tmp_path):
"""core.hooksPath (husky-style): the hook must land where git runs it."""
repo = self._init_real_repo(tmp_path / "husky")
self._git("config", "core.hooksPath", ".husky", cwd=repo)
hook_path = install_git_hook(repo)
assert hook_path is not None
expected = repo / ".husky" / "pre-commit"
assert hook_path.resolve() == expected.resolve()
assert os.access(hook_path, os.X_OK)
assert "code-review-graph detect-changes" in hook_path.read_text()
# The default location must NOT be used — git would never run it.
assert not (repo / ".git" / "hooks" / "pre-commit").exists()
def test_linked_worktree_installs_where_git_runs_hooks(self, tmp_path):
"""Linked worktree: .git is a file; the hook must still be installed
into the hooks path git actually consults (issue #313)."""
main = self._init_real_repo(tmp_path / "main")
self._git(
"-c", "[email protected]", "-c", "user.name=Test",
"commit", "--allow-empty", "-m", "init", cwd=main,
)
worktree = tmp_path / "wt"
self._git("worktree", "add", str(worktree), "-b", "wt-branch", cwd=main)
assert (worktree / ".git").is_file() # precondition: not a directory
hook_path = install_git_hook(worktree)
assert hook_path is not None
git_hooks_dir = worktree / self._git(
"rev-parse", "--git-path", "hooks", cwd=worktree
)
assert hook_path.resolve() == (git_hooks_dir / "pre-commit").resolve()
assert "code-review-graph detect-changes" in hook_path.read_text()
class TestInstallHooks:
def test_creates_settings_file(self, tmp_path):
install_hooks(tmp_path)
settings_path = tmp_path / ".claude" / "settings.json"
assert settings_path.exists()
data = json.loads(settings_path.read_text())
assert "hooks" in data
def test_merges_with_existing(self, tmp_path):
settings_dir = tmp_path / ".claude"
settings_dir.mkdir(parents=True)
existing = {"customSetting": True, "hooks": {"OtherHook": []}}
(settings_dir / "settings.json").write_text(json.dumps(existing))
install_hooks(tmp_path)
data = json.loads((settings_dir / "settings.json").read_text())
assert data["customSetting"] is True
assert "OtherHook" in data["hooks"]
assert "PostToolUse" in data["hooks"]
assert "SessionStart" in data["hooks"]
assert "PreCommit" not in data["hooks"]
assert "OtherHook" in data["hooks"] # pre-existing hooks must not be clobbered
def test_creates_settings_backup(self, tmp_path):
settings_dir = tmp_path / ".claude"
settings_dir.mkdir(parents=True)
existing = {"hooks": {"OtherHook": []}}
(settings_dir / "settings.json").write_text(json.dumps(existing))
install_hooks(tmp_path)
backup_path = settings_dir / "settings.json.bak"
assert backup_path.exists()
backup = json.loads(backup_path.read_text())
assert backup == existing
def test_creates_claude_directory(self, tmp_path):
install_hooks(tmp_path)
assert (tmp_path / ".claude").is_dir()
class TestGenerateCodexHooksConfig:
def test_returns_dict_with_hooks(self, tmp_path):
config = generate_codex_hooks_config(tmp_path)
assert "hooks" in config
def test_has_post_tool_use(self, tmp_path):
config = generate_codex_hooks_config(tmp_path)
assert "PostToolUse" in config["hooks"]
entry = config["hooks"]["PostToolUse"][0]
assert entry["matcher"] == "Write|Edit|Bash"
inner = entry["hooks"][0]
assert inner["type"] == "command"
assert "update" in inner["command"]
assert inner["command"].startswith("cat >/dev/null || true; ")
assert inner["statusMessage"] == "Updating code-review-graph"
def test_has_session_start(self, tmp_path):
config = generate_codex_hooks_config(tmp_path)
assert "SessionStart" in config["hooks"]
entry = config["hooks"]["SessionStart"][0]
assert entry["matcher"] == "startup|resume"
inner = entry["hooks"][0]
assert inner["type"] == "command"
assert "status" in inner["command"]
assert inner["command"].startswith("cat >/dev/null || true; ")
assert inner["statusMessage"] == "Checking code-review-graph status"
def test_post_tool_use_command_handles_large_stdin_payload(self, tmp_path):
config = generate_codex_hooks_config(tmp_path)
cmd = config["hooks"]["PostToolUse"][0]["hooks"][0]["command"]
payload = ("x" * 1024 + "\n") * 20000
proc = subprocess.Popen(
["bash", "-lc", cmd],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
cwd=tmp_path,
)
broken_pipe = None
try:
assert proc.stdin is not None
proc.stdin.write(payload)
proc.stdin.close()
except BrokenPipeError as exc: # pragma: no cover - regression guard
broken_pipe = exc
proc.stdin = None
stdout, stderr = proc.communicate()
assert broken_pipe is None, f"hook command raised BrokenPipeError: {stderr}"
assert proc.returncode == 0, stderr
def test_commands_do_not_pin_a_specific_repo_path(self, tmp_path):
config = generate_codex_hooks_config(tmp_path / "repo with spaces")
post_cmd = config["hooks"]["PostToolUse"][0]["hooks"][0]["command"]
session_cmd = config["hooks"]["SessionStart"][0]["hooks"][0]["command"]
assert "--repo" not in post_cmd
assert "--repo" not in session_cmd
assert "code-review-graph update --skip-flows" in post_cmd
assert "code-review-graph status" in session_cmd
class TestInstallCodexHooks:
def test_creates_hooks_file(self, tmp_path, monkeypatch):
# Path.home() ignores HOME on Windows; patch it like the cursor tests do.
monkeypatch.setattr("code_review_graph.skills.Path.home", lambda: tmp_path)
hooks_path = install_codex_hooks(tmp_path / "repo")
assert hooks_path == tmp_path / ".codex" / "hooks.json"
assert hooks_path.exists()
data = json.loads(hooks_path.read_text())
assert "hooks" in data
assert "PostToolUse" in data["hooks"]
assert "SessionStart" in data["hooks"]
def test_merges_with_existing(self, tmp_path, monkeypatch):
# Path.home() ignores HOME on Windows; patch it like the cursor tests do.
monkeypatch.setattr("code_review_graph.skills.Path.home", lambda: tmp_path)
codex_dir = tmp_path / ".codex"
codex_dir.mkdir(parents=True)
existing = {
"customSetting": True,
"hooks": {
"Stop": [{"hooks": [{"type": "command", "command": "echo stop"}]}],
},
}
(codex_dir / "hooks.json").write_text(json.dumps(existing), encoding="utf-8")
install_codex_hooks(tmp_path / "repo")
data = json.loads((codex_dir / "hooks.json").read_text())
assert data["customSetting"] is True
assert "Stop" in data["hooks"]
assert "PostToolUse" in data["hooks"]
assert "SessionStart" in data["hooks"]
def test_creates_hooks_backup(self, tmp_path, monkeypatch):
# Path.home() ignores HOME on Windows; patch it like the cursor tests do.
monkeypatch.setattr("code_review_graph.skills.Path.home", lambda: tmp_path)
codex_dir = tmp_path / ".codex"
codex_dir.mkdir(parents=True)
existing = {"hooks": {"Stop": []}}
hooks_path = codex_dir / "hooks.json"
hooks_path.write_text(json.dumps(existing), encoding="utf-8")
install_codex_hooks(tmp_path / "repo")
backup_path = codex_dir / "hooks.json.bak"
assert backup_path.exists()
backup = json.loads(backup_path.read_text())
assert backup == existing
def test_idempotent_by_command(self, tmp_path, monkeypatch):
# Path.home() ignores HOME on Windows; patch it like the cursor tests do.
monkeypatch.setattr("code_review_graph.skills.Path.home", lambda: tmp_path)
repo_root = tmp_path / "repo"
install_codex_hooks(repo_root)
install_codex_hooks(repo_root)
data = json.loads((tmp_path / ".codex" / "hooks.json").read_text())
assert len(data["hooks"]["PostToolUse"]) == 1
assert len(data["hooks"]["SessionStart"]) == 1
def test_install_qoder_hooks(self, tmp_path):
install_hooks(tmp_path, platform="qoder")
settings_path = tmp_path / ".qoder" / "settings.json"
assert settings_path.exists()
data = json.loads(settings_path.read_text())
assert "hooks" in data
assert "PostToolUse" in data["hooks"]
assert "SessionStart" in data["hooks"]
def test_install_qoder_hooks_merges_existing(self, tmp_path):
settings_dir = tmp_path / ".qoder"
settings_dir.mkdir(parents=True)
existing = {"customSetting": True}
(settings_dir / "settings.json").write_text(json.dumps(existing))
install_hooks(tmp_path, platform="qoder")
data = json.loads((settings_dir / "settings.json").read_text())
assert data["customSetting"] is True
assert "hooks" in data
class TestInjectClaudeMd:
def test_creates_section_in_new_file(self, tmp_path):
inject_claude_md(tmp_path)
content = (tmp_path / "CLAUDE.md").read_text()
assert _CLAUDE_MD_SECTION_MARKER in content
assert "MCP Tools" in content
def test_appends_to_existing_file(self, tmp_path):
claude_md = tmp_path / "CLAUDE.md"
claude_md.write_text("# My Project\n\nExisting content.\n")
inject_claude_md(tmp_path)
content = claude_md.read_text()
assert "# My Project" in content
assert "Existing content." in content
assert _CLAUDE_MD_SECTION_MARKER in content
def test_idempotent(self, tmp_path):
"""Running twice should not duplicate the section."""
inject_claude_md(tmp_path)
first_content = (tmp_path / "CLAUDE.md").read_text()
inject_claude_md(tmp_path)
second_content = (tmp_path / "CLAUDE.md").read_text()
assert first_content == second_content
assert second_content.count(_CLAUDE_MD_SECTION_MARKER) == 1
def test_idempotent_with_existing_content(self, tmp_path):
claude_md = tmp_path / "CLAUDE.md"
claude_md.write_text("# Existing\n")
inject_claude_md(tmp_path)
first_content = claude_md.read_text()
inject_claude_md(tmp_path)
second_content = claude_md.read_text()
assert first_content == second_content
assert second_content.count(_CLAUDE_MD_SECTION_MARKER) == 1
class TestInjectPlatformInstructionsFiltering:
def test_all_writes_every_file(self, tmp_path):
updated = inject_platform_instructions(tmp_path, target="all")
assert set(updated) == {
"AGENTS.md", "GEMINI.md", ".cursorrules", ".windsurfrules",
"QODER.md", ".kiro/steering/code-review-graph.md",
".github/instructions/code-review-graph.instructions.md",
"CODEBUDDY.md",
}
def test_default_is_all(self, tmp_path):
updated = inject_platform_instructions(tmp_path)
assert set(updated) == {
"AGENTS.md", "GEMINI.md", ".cursorrules", ".windsurfrules",
"QODER.md", ".kiro/steering/code-review-graph.md",
".github/instructions/code-review-graph.instructions.md",
"CODEBUDDY.md",
}
def test_claude_writes_nothing(self, tmp_path):
updated = inject_platform_instructions(tmp_path, target="claude")
assert updated == []
assert not (tmp_path / "AGENTS.md").exists()
assert not (tmp_path / "GEMINI.md").exists()
assert not (tmp_path / ".cursorrules").exists()
assert not (tmp_path / ".windsurfrules").exists()
assert not (tmp_path / "QODER.md").exists()
assert not (
tmp_path
/ ".github"
/ "instructions"
/ "code-review-graph.instructions.md"
).exists()
def test_cursor_writes_only_cursor_files(self, tmp_path):
updated = inject_platform_instructions(tmp_path, target="cursor")
assert set(updated) == {"AGENTS.md", ".cursorrules"}
assert not (tmp_path / "GEMINI.md").exists()
assert not (tmp_path / ".windsurfrules").exists()
assert not (tmp_path / "QODER.md").exists()
def test_windsurf_writes_only_windsurfrules(self, tmp_path):
updated = inject_platform_instructions(tmp_path, target="windsurf")
assert updated == [".windsurfrules"]
def test_antigravity_writes_agents_and_gemini(self, tmp_path):
updated = inject_platform_instructions(tmp_path, target="antigravity")
assert set(updated) == {"AGENTS.md", "GEMINI.md"}
def test_gemini_cli_writes_only_gemini_md(self, tmp_path):
updated = inject_platform_instructions(tmp_path, target="gemini-cli")
assert updated == ["GEMINI.md"]
assert not (tmp_path / "AGENTS.md").exists()
assert not (tmp_path / ".cursorrules").exists()
assert not (tmp_path / ".windsurfrules").exists()
assert not (tmp_path / "QODER.md").exists()
def test_opencode_writes_only_agents(self, tmp_path):
updated = inject_platform_instructions(tmp_path, target="opencode")
assert updated == ["AGENTS.md"]
def test_codex_writes_only_agents(self, tmp_path):
updated = inject_platform_instructions(tmp_path, target="codex")
assert updated == ["AGENTS.md"]
assert not (tmp_path / "GEMINI.md").exists()
assert not (tmp_path / ".cursorrules").exists()
assert not (tmp_path / ".windsurfrules").exists()
assert not (tmp_path / "QODER.md").exists()
content = (tmp_path / "AGENTS.md").read_text(encoding="utf-8")
assert _CLAUDE_MD_SECTION_MARKER in content
def test_qoder_writes_only_qoder_md(self, tmp_path):
updated = inject_platform_instructions(tmp_path, target="qoder")
assert updated == ["QODER.md"]
assert not (tmp_path / "AGENTS.md").exists()
assert not (tmp_path / "GEMINI.md").exists()
assert not (tmp_path / ".cursorrules").exists()
assert not (tmp_path / ".windsurfrules").exists()
def test_codebuddy_writes_only_codebuddy_md_and_is_idempotent(self, tmp_path):
first = inject_platform_instructions(tmp_path, target="codebuddy")
second = inject_platform_instructions(tmp_path, target="codebuddy")
assert first == ["CODEBUDDY.md"]
assert second == []
content = (tmp_path / "CODEBUDDY.md").read_text(encoding="utf-8")
assert content.count(_CLAUDE_MD_SECTION_MARKER) == 1
assert "detect_changes_tool" in content
assert not (tmp_path / "CLAUDE.md").exists()
assert not (tmp_path / "AGENTS.md").exists()
class TestCodeBuddyPlatform:
def test_platform_uses_official_project_mcp_contract(self):
assert "codebuddy" in PLATFORMS
platform = PLATFORMS["codebuddy"]
assert platform["name"] == "CodeBuddy Code"
assert platform["config_path"](Path("/tmp/project")) == Path(
"/tmp/project/.mcp.json"
)
assert platform["key"] == "mcpServers"
assert platform["format"] == "object"
assert platform["needs_type"] is True
def test_install_preserves_jsonc_content(self, tmp_path):
mcp_path = tmp_path / ".mcp.json"
mcp_path.write_text(
"{\n"
" // CodeBuddy supports JSONC in project MCP files\n"
' "dashboard": "https://example.test/a,b",\n'
' "mcpServers": {\n'
' "existing": {"command": "existing"},\n'
" },\n"
"}\n",
encoding="utf-8",
)
configured = install_platform_configs(tmp_path, target="codebuddy")
assert configured == ["CodeBuddy Code"]
data = json.loads(mcp_path.read_text(encoding="utf-8"))
assert data["dashboard"] == "https://example.test/a,b"
assert data["mcpServers"]["existing"]["command"] == "existing"
assert data["mcpServers"]["code-review-graph"]["type"] == "stdio"
def test_all_dedupes_only_claude_and_codebuddy_shared_contract(
self, tmp_path, capsys
):
shared_path = tmp_path / ".mcp.json"
other_platform = {
"name": "Other shared client",
"config_path": lambda root: shared_path,
"key": "servers",
"detect": lambda: True,
"format": "object",
"needs_type": False,
}
with patch.dict(
PLATFORMS,
{
"claude": {**PLATFORMS["claude"], "detect": lambda: True},
"codebuddy": {**PLATFORMS["codebuddy"], "detect": lambda: True},
"other-shared": other_platform,
},
clear=True,
):
configured = install_platform_configs(tmp_path, target="all")
assert configured == ["Claude Code", "CodeBuddy Code", "Other shared client"]
data = json.loads(shared_path.read_text(encoding="utf-8"))
assert "code-review-graph" in data["mcpServers"]
assert "code-review-graph" in data["servers"]
# Claude and CodeBuddy share one exact contract/write. A different
# contract that happens to share the path must still be processed.
assert capsys.readouterr().out.count(f"configured {shared_path}") == 2
def test_all_does_not_credit_shared_alias_when_write_is_unsafe(
self, tmp_path, capsys
):
original = "{ this is not valid JSONC }\n"
(tmp_path / ".mcp.json").write_text(original, encoding="utf-8")
with patch.dict(
PLATFORMS,
{
"claude": {**PLATFORMS["claude"], "detect": lambda: True},
"codebuddy": {**PLATFORMS["codebuddy"], "detect": lambda: True},
},
clear=True,
):
configured = install_platform_configs(tmp_path, target="all")
assert configured == []
assert (tmp_path / ".mcp.json").read_text(encoding="utf-8") == original
assert "skipping to avoid data loss" in capsys.readouterr().out
def test_project_skills_use_uppercase_skill_file(self, tmp_path):
from code_review_graph.skills import install_codebuddy_skills
skills_root = install_codebuddy_skills(tmp_path)
assert skills_root == tmp_path / ".codebuddy" / "skills"
assert {path.name for path in skills_root.iterdir()} == {
"debug-issue",
"explore-codebase",
"refactor-safely",
"review-changes",
}
for skill_dir in skills_root.iterdir():
content = (skill_dir / "SKILL.md").read_text(encoding="utf-8")
assert content.startswith("---\n")
assert f"name: {skill_dir.name}\n" in content
assert "description:" in content
assert "get_minimal_context" in content
def test_project_hooks_preserve_user_settings_and_resolve_repo_at_runtime(
self, tmp_path
):
from code_review_graph.skills import install_codebuddy_hooks
repo_root = tmp_path / "repo with spaces"
settings_path = repo_root / ".codebuddy" / "settings.json"
settings_path.parent.mkdir(parents=True)
user_hook = {
"matcher": "Read",
"hooks": [{"type": "command", "command": "echo user"}],
}
settings_path.write_text(
json.dumps(
{
"model": "custom-model",
"hooks": {"PostToolUse": [user_hook]},
}
),
encoding="utf-8",
)
result = install_codebuddy_hooks(repo_root)
assert result == settings_path
assert settings_path.with_suffix(".json.bak").exists()
data = json.loads(settings_path.read_text(encoding="utf-8"))
assert data["model"] == "custom-model"
assert user_hook in data["hooks"]["PostToolUse"]
installed = [
hook
for entries in data["hooks"].values()
for entry in entries
for hook in entry["hooks"]
if "code-review-graph" in hook.get("command", "")
]
assert installed
for hook in installed:
command = hook["command"]
assert "command -v code-review-graph" in command
assert "git rev-parse --show-toplevel" in command
assert str(repo_root) not in command
crg_entry = next(
entry
for entry in data["hooks"]["PostToolUse"]
if any("code-review-graph" in hook.get("command", "") for hook in entry["hooks"])
)
assert crg_entry["matcher"] == "Edit|Write|Bash"
first = settings_path.read_text(encoding="utf-8")
install_codebuddy_hooks(repo_root)
assert settings_path.read_text(encoding="utf-8") == first
class TestInstallPlatformConfigs:
@_needs_tomllib
def test_install_codex_config(self, tmp_path):
codex_config = tmp_path / ".codex" / "config.toml"
with patch.dict(
PLATFORMS,
{
"codex": {
**PLATFORMS["codex"],
"config_path": lambda root: codex_config,
"detect": lambda: True,
},
},
):
configured = install_platform_configs(tmp_path, target="codex")
assert "Codex" in configured
data = tomllib.loads(codex_config.read_text())
entry = data["mcp_servers"]["code-review-graph"]
assert entry["type"] == "stdio"
assert "serve" in entry["args"]
@_needs_tomllib
def test_install_codex_preserves_existing_toml(self, tmp_path):
codex_config = tmp_path / ".codex" / "config.toml"
codex_config.parent.mkdir(parents=True)
codex_config.write_text(
'model = "gpt-5.4"\n\n[mcp_servers.other]\ncommand = "other"\n',
encoding="utf-8",
)
with patch.dict(
PLATFORMS,
{
"codex": {
**PLATFORMS["codex"],
"config_path": lambda root: codex_config,
"detect": lambda: True,
},
},
):
install_platform_configs(tmp_path, target="codex")
data = tomllib.loads(codex_config.read_text())
assert data["model"] == "gpt-5.4"
assert data["mcp_servers"]["other"]["command"] == "other"
expected_cmd, _ = _detect_serve_command()
assert data["mcp_servers"]["code-review-graph"]["command"] == expected_cmd
def test_install_codex_no_duplicate(self, tmp_path):
codex_config = tmp_path / ".codex" / "config.toml"
codex_config.parent.mkdir(parents=True)
codex_config.write_text(
"\n".join(
[
"[mcp_servers.code-review-graph]",
'command = "uvx"',
'args = ["code-review-graph", "serve"]',
'type = "stdio"',
"",
]
),
encoding="utf-8",
)
with patch.dict(
PLATFORMS,
{
"codex": {
**PLATFORMS["codex"],
"config_path": lambda root: codex_config,
"detect": lambda: True,
},
},
):
install_platform_configs(tmp_path, target="codex")
assert codex_config.read_text().count("[mcp_servers.code-review-graph]") == 1
def test_install_cursor_config(self, tmp_path):
with patch.dict(
PLATFORMS,
{
"cursor": {**PLATFORMS["cursor"], "detect": lambda: True},
},
):
configured = install_platform_configs(tmp_path, target="cursor")
assert "Cursor" in configured
config_path = tmp_path / ".cursor" / "mcp.json"
assert config_path.exists()
data = json.loads(config_path.read_text())
assert "code-review-graph" in data["mcpServers"]
assert data["mcpServers"]["code-review-graph"]["type"] == "stdio"
def test_install_windsurf_config(self, tmp_path):
windsurf_dir = tmp_path / ".codeium" / "windsurf"
windsurf_dir.mkdir(parents=True)
config_path = windsurf_dir / "mcp_config.json"
with patch.dict(
PLATFORMS,
{
"windsurf": {
**PLATFORMS["windsurf"],
"config_path": lambda root: config_path,
"detect": lambda: True,
},
},
):
configured = install_platform_configs(tmp_path, target="windsurf")
assert "Windsurf" in configured
data = json.loads(config_path.read_text())
entry = data["mcpServers"]["code-review-graph"]
assert "type" not in entry
expected_cmd, _ = _detect_serve_command()
assert entry["command"] == expected_cmd
def test_install_zed_config(self, tmp_path):
zed_settings = tmp_path / "zed" / "settings.json"
zed_settings.parent.mkdir(parents=True)
with patch.dict(
PLATFORMS,
{
"zed": {
**PLATFORMS["zed"],
"config_path": lambda root: zed_settings,
"detect": lambda: True,
},
},
):
configured = install_platform_configs(tmp_path, target="zed")
assert "Zed" in configured
data = json.loads(zed_settings.read_text())
assert "context_servers" in data
assert "code-review-graph" in data["context_servers"]
def test_install_continue_config(self, tmp_path):
continue_dir = tmp_path / ".continue"
continue_dir.mkdir()
config_path = continue_dir / "config.json"
with patch.dict(
PLATFORMS,
{
"continue": {
**PLATFORMS["continue"],
"config_path": lambda root: config_path,
"detect": lambda: True,
},
},
):
configured = install_platform_configs(tmp_path, target="continue")
assert "Continue" in configured
data = json.loads(config_path.read_text())
assert isinstance(data["mcpServers"], list)
assert data["mcpServers"][0]["name"] == "code-review-graph"
assert data["mcpServers"][0]["type"] == "stdio"
def test_install_opencode_config(self, tmp_path):
configured = install_platform_configs(tmp_path, target="opencode")
assert "OpenCode" in configured
config_path = tmp_path / "opencode.jsonc"
data = json.loads(config_path.read_text())
entry = data["mcp"]["code-review-graph"]
command, args = _detect_serve_command()
assert entry == {
"type": "local",
"command": [command, *args, "--repo", str(tmp_path)],
}
assert "cwd" not in entry
def test_install_opencode_prefers_existing_jsonc_and_preserves_servers(self, tmp_path):
config_path = tmp_path / "opencode.jsonc"
config_path.write_text(
'{\n // keep this server\n "mcp": {\n'
' "other": {"type": "local", "command": ["other"]},\n'
" },\n}\n",
encoding="utf-8",
)
(tmp_path / "opencode.json").write_text("{}", encoding="utf-8")
install_platform_configs(tmp_path, target="opencode")
data = json.loads(config_path.read_text(encoding="utf-8"))
assert "other" in data["mcp"]
assert "code-review-graph" in data["mcp"]
assert (tmp_path / "opencode.json").read_text(encoding="utf-8") == "{}"
def test_install_opencode_uses_existing_json(self, tmp_path):
config_path = tmp_path / "opencode.json"
config_path.write_text(json.dumps({"mcp": {"other": {}}}), encoding="utf-8")
install_platform_configs(tmp_path, target="opencode")
data = json.loads(config_path.read_text(encoding="utf-8"))
assert "other" in data["mcp"]
assert "code-review-graph" in data["mcp"]
assert not (tmp_path / "opencode.jsonc").exists()
def test_install_opencode_warns_about_legacy_dotfile(self, tmp_path, capsys):
legacy = tmp_path / ".opencode.json"
legacy.write_text(
json.dumps({"mcpServers": {"code-review-graph": {"command": "uvx"}}}),
encoding="utf-8",
)
install_platform_configs(tmp_path, target="opencode")
output = capsys.readouterr().out
assert ".opencode.json" in output
assert "legacy" in output.lower()
assert legacy.exists()
assert (tmp_path / "opencode.jsonc").exists()
def test_install_gemini_cli_config(self, tmp_path):
gemini_config = tmp_path / ".gemini" / "settings.json"
with patch.dict(
PLATFORMS,
{
"gemini-cli": {
**PLATFORMS["gemini-cli"],
"config_path": lambda root: gemini_config,
"detect": lambda: True,
},
},
):
configured = install_platform_configs(tmp_path, target="gemini-cli")
assert "Gemini CLI" in configured
data = json.loads(gemini_config.read_text())
entry = data["mcpServers"]["code-review-graph"]
assert "type" not in entry
assert entry["args"][-1] == "serve"
def test_install_qwen_config(self, tmp_path):
"""Qwen Code uses ~/.qwen/settings.json with mcpServers (see #83)."""
qwen_config = tmp_path / ".qwen" / "settings.json"
with patch.dict(
PLATFORMS,
{
"qwen": {
**PLATFORMS["qwen"],
"config_path": lambda root: qwen_config,
"detect": lambda: True,
},
},
):
configured = install_platform_configs(tmp_path, target="qwen")
assert "Qwen Code" in configured
data = json.loads(qwen_config.read_text())
entry = data["mcpServers"]["code-review-graph"]
assert entry["type"] == "stdio"
assert entry["args"][-1] == "serve"
def test_install_qwen_preserves_existing_servers(self, tmp_path):
"""Adding qwen should merge with, not clobber, existing mcpServers."""
qwen_config = tmp_path / ".qwen" / "settings.json"
qwen_config.parent.mkdir(parents=True)
qwen_config.write_text(
json.dumps({"mcpServers": {"other-server": {"command": "other"}}}),
encoding="utf-8",
)
with patch.dict(
PLATFORMS,
{
"qwen": {
**PLATFORMS["qwen"],
"config_path": lambda root: qwen_config,
"detect": lambda: True,
},
},
):
install_platform_configs(tmp_path, target="qwen")
data = json.loads(qwen_config.read_text())
assert "other-server" in data["mcpServers"]
assert "code-review-graph" in data["mcpServers"]
def test_install_all_detected(self, tmp_path):
"""Installing 'all' configures auto-detected platforms."""
codex_config = tmp_path / ".codex" / "config.toml"
with patch.dict(
PLATFORMS,
{
"codex": {
**PLATFORMS["codex"],
"config_path": lambda root: codex_config,
"detect": lambda: True,
},
"claude": {**PLATFORMS["claude"], "detect": lambda: True},
"opencode": {**PLATFORMS["opencode"], "detect": lambda: True},
"cursor": {**PLATFORMS["cursor"], "detect": lambda: False},
"windsurf": {**PLATFORMS["windsurf"], "detect": lambda: False},
"zed": {**PLATFORMS["zed"], "detect": lambda: False},
"continue": {**PLATFORMS["continue"], "detect": lambda: False},
"antigravity": {**PLATFORMS["antigravity"], "detect": lambda: False},
"gemini-cli": {**PLATFORMS["gemini-cli"], "detect": lambda: False},
},
):
with patch("code_review_graph.skills.Path.home", return_value=tmp_path):
configured = install_platform_configs(tmp_path, target="all")
assert "Codex" in configured
assert "Claude Code" in configured
assert "OpenCode" in configured
assert codex_config.exists()
assert (tmp_path / ".mcp.json").exists()
assert (tmp_path / "opencode.jsonc").exists()
def test_merge_existing_servers(self, tmp_path):
"""Should not overwrite existing MCP servers."""
mcp_path = tmp_path / ".mcp.json"
existing = {"mcpServers": {"other-server": {"command": "other"}}}
mcp_path.write_text(json.dumps(existing))
install_platform_configs(tmp_path, target="claude")
data = json.loads(mcp_path.read_text())
assert "other-server" in data["mcpServers"]
assert "code-review-graph" in data["mcpServers"]
def test_dry_run_no_write(self, tmp_path):
configured = install_platform_configs(tmp_path, target="claude", dry_run=True)
assert "Claude Code" in configured
assert not (tmp_path / ".mcp.json").exists()
def test_already_configured_skips(self, tmp_path):
install_platform_configs(tmp_path, target="claude")
configured = install_platform_configs(tmp_path, target="claude")
assert "Claude Code" in configured
def test_continue_array_no_duplicate(self, tmp_path):
config_path = tmp_path / ".continue" / "config.json"
config_path.parent.mkdir(parents=True)
existing = {
"mcpServers": [{"name": "code-review-graph", "command": "uvx", "args": ["serve"]}]
}
config_path.write_text(json.dumps(existing))
with patch.dict(
PLATFORMS,
{
"continue": {
**PLATFORMS["continue"],
"config_path": lambda root: config_path,
"detect": lambda: True,
},
},
):
install_platform_configs(tmp_path, target="continue")
data = json.loads(config_path.read_text())
assert len(data["mcpServers"]) == 1
def test_install_qoder_config(self, tmp_path):
qoder_config = tmp_path / ".qoder" / "mcp.json"
with patch.dict(
PLATFORMS,
{
"qoder": {
**PLATFORMS["qoder"],
"config_path": lambda root: qoder_config,
"detect": lambda: True,
},
},
):
configured = install_platform_configs(tmp_path, target="qoder")
assert "Qoder" in configured
data = json.loads(qoder_config.read_text())
assert "mcpServers" in data
assert "code-review-graph" in data["mcpServers"]
assert data["mcpServers"]["code-review-graph"]["type"] == "stdio"
expected_cmd, _ = _detect_serve_command()
assert data["mcpServers"]["code-review-graph"]["command"] == expected_cmd
class TestGeminiCLIInstall:
def test_install_gemini_cli_hooks_creates_settings_and_scripts(self, tmp_path):
settings_dir = tmp_path / ".gemini"
settings_dir.mkdir(parents=True, exist_ok=True)
settings_path = settings_dir / "settings.json"
settings_path.write_text(json.dumps({"customSetting": True}) + "\n", encoding="utf-8")
out_path = install_gemini_cli_hooks(tmp_path)
assert out_path == settings_path
assert (settings_dir / "settings.json.bak").exists()
data = json.loads(settings_path.read_text(encoding="utf-8"))
assert data["customSetting"] is True
assert "hooks" in data
assert "SessionStart" in data["hooks"]
assert "AfterTool" in data["hooks"]
session_start = settings_dir / "hooks" / "crg-session-start.sh"
update = settings_dir / "hooks" / "crg-update.sh"
assert session_start.exists()
assert update.exists()
assert os.access(session_start, os.X_OK)
assert os.access(update, os.X_OK)
def test_install_gemini_cli_skills_writes_skill_dirs(self, tmp_path):
skills_root = install_gemini_cli_skills(tmp_path)
assert skills_root == tmp_path / ".gemini" / "skills"
skill_path = skills_root / "explore-codebase" / "SKILL.md"
assert skill_path.exists()
text = skill_path.read_text(encoding="utf-8")
assert text.startswith("---\n")
assert "name: explore-codebase" in text
assert "description:" in text
class TestCursorHooksConfig:
"""Tests for generate_cursor_hooks_config()."""
def test_has_version_1(self):
config = generate_cursor_hooks_config()
assert config["version"] == 1
def test_has_after_file_edit(self):
config = generate_cursor_hooks_config()
hooks = config["hooks"]["afterFileEdit"]
assert len(hooks) >= 1
assert "crg-update.sh" in hooks[0]["command"]
assert hooks[0]["timeout"] == 5
def test_has_session_start(self):
config = generate_cursor_hooks_config()
hooks = config["hooks"]["sessionStart"]
assert len(hooks) >= 1
assert "crg-session-start.sh" in hooks[0]["command"]
assert hooks[0]["timeout"] == 5
def test_has_before_shell_execution(self):
config = generate_cursor_hooks_config()
hooks = config["hooks"]["beforeShellExecution"]
assert len(hooks) >= 1
assert "crg-pre-commit.sh" in hooks[0]["command"]
assert hooks[0]["timeout"] == 10
assert hooks[0]["matcher"] == "^git\\s+commit"
def test_has_all_three_hook_types(self):
config = generate_cursor_hooks_config()
hook_types = set(config["hooks"].keys())
assert hook_types == {"afterFileEdit", "sessionStart", "beforeShellExecution"}
def test_commands_point_to_home_cursor_hooks(self):
config = generate_cursor_hooks_config()
from pathlib import Path
hooks_dir = str(Path.home() / ".cursor" / "hooks")
for event, entries in config["hooks"].items():
for entry in entries:
assert entry["command"].startswith(hooks_dir), (
f"{event} command does not start with {hooks_dir}"
)
class TestCursorHookScripts:
"""Tests for _cursor_hook_scripts()."""
def test_returns_three_scripts(self):
scripts = _cursor_hook_scripts()
assert set(scripts.keys()) == {
"crg-update.sh",
"crg-session-start.sh",
"crg-pre-commit.sh",
}
def test_scripts_start_with_shebang(self):
scripts = _cursor_hook_scripts()
for name, content in scripts.items():
assert content.startswith("#!/usr/bin/env bash"), f"{name} missing shebang line"
def test_scripts_exit_zero(self):
"""Each script must end with exit 0 for graceful failure."""
scripts = _cursor_hook_scripts()
for name, content in scripts.items():
assert "exit 0" in content, f"{name} missing 'exit 0'"
def test_scripts_consume_stdin(self):
"""Each script must consume stdin (Cursor protocol)."""
scripts = _cursor_hook_scripts()
for name, content in scripts.items():
assert "cat > /dev/null" in content, f"{name} missing stdin consumption"
def test_update_script_runs_update(self):
scripts = _cursor_hook_scripts()
assert "code-review-graph update --skip-flows" in scripts["crg-update.sh"]
def test_session_start_script_runs_status(self):
scripts = _cursor_hook_scripts()
assert "code-review-graph status" in scripts["crg-session-start.sh"]
def test_pre_commit_script_runs_detect_changes(self):
scripts = _cursor_hook_scripts()
assert "code-review-graph detect-changes --brief" in scripts["crg-pre-commit.sh"]
class TestInstallCursorHooks:
"""Tests for install_cursor_hooks()."""
def test_creates_hooks_json(self, tmp_path):
with patch("code_review_graph.skills.Path.home", return_value=tmp_path):
result = install_cursor_hooks()
hooks_json = tmp_path / ".cursor" / "hooks.json"
assert hooks_json.exists()
assert result == hooks_json
data = json.loads(hooks_json.read_text())
assert data["version"] == 1
assert "afterFileEdit" in data["hooks"]
def test_creates_hook_scripts(self, tmp_path):
with patch("code_review_graph.skills.Path.home", return_value=tmp_path):
install_cursor_hooks()
hooks_dir = tmp_path / ".cursor" / "hooks"
assert (hooks_dir / "crg-update.sh").exists()
assert (hooks_dir / "crg-session-start.sh").exists()
assert (hooks_dir / "crg-pre-commit.sh").exists()
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX exec bits")
def test_scripts_are_executable(self, tmp_path):
with patch("code_review_graph.skills.Path.home", return_value=tmp_path):
install_cursor_hooks()
hooks_dir = tmp_path / ".cursor" / "hooks"
for script in hooks_dir.iterdir():
mode = script.stat().st_mode
assert mode & stat.S_IXUSR, f"{script.name} not executable by owner"
assert mode & stat.S_IXGRP, f"{script.name} not executable by group"
def test_merges_with_existing_hooks_json(self, tmp_path):
cursor_dir = tmp_path / ".cursor"
cursor_dir.mkdir(parents=True)
existing = {
"version": 1,
"hooks": {
"afterFileEdit": [{"command": "/some/other/hook.sh", "timeout": 3}],
"stop": [{"command": "/some/stop-hook.sh", "timeout": 2}],
},
}
(cursor_dir / "hooks.json").write_text(json.dumps(existing))
with patch("code_review_graph.skills.Path.home", return_value=tmp_path):
install_cursor_hooks()
data = json.loads((cursor_dir / "hooks.json").read_text())
# Original hook preserved
commands = [h["command"] for h in data["hooks"]["afterFileEdit"]]
assert "/some/other/hook.sh" in commands
# Our hook added
assert any("crg-update.sh" in c for c in commands)
# Unrelated hook type preserved
assert "stop" in data["hooks"]
def test_no_duplicate_on_reinstall(self, tmp_path):
with patch("code_review_graph.skills.Path.home", return_value=tmp_path):
install_cursor_hooks()
install_cursor_hooks()
data = json.loads((tmp_path / ".cursor" / "hooks.json").read_text())
# Each event type should have exactly 1 crg hook
for event, entries in data["hooks"].items():
crg_hooks = [h for h in entries if "crg-" in h.get("command", "")]
assert len(crg_hooks) == 1, f"{event} has {len(crg_hooks)} crg hooks after reinstall"
def test_handles_corrupt_existing_json(self, tmp_path):
cursor_dir = tmp_path / ".cursor"
cursor_dir.mkdir(parents=True)
(cursor_dir / "hooks.json").write_text("not valid json{{{")
with patch("code_review_graph.skills.Path.home", return_value=tmp_path):
result = install_cursor_hooks()
assert result.exists()
data = json.loads(result.read_text())
assert data["version"] == 1
class TestKiroPlatform:
"""Tests for Kiro platform support."""
def test_kiro_platform_entry_exists(self):
"""PLATFORMS dict has a 'kiro' key with correct metadata."""
assert "kiro" in PLATFORMS
kiro = PLATFORMS["kiro"]
assert kiro["name"] == "Kiro"
assert kiro["key"] == "mcpServers"
assert kiro["format"] == "object"
assert kiro["needs_type"] is True
def test_install_kiro_config(self, tmp_path):
"""install_platform_configs creates .kiro/settings/mcp.json."""
configured = install_platform_configs(tmp_path, target="kiro")
assert "Kiro" in configured
config_path = tmp_path / ".kiro" / "settings" / "mcp.json"
assert config_path.exists()
data = json.loads(config_path.read_text())
assert "code-review-graph" in data["mcpServers"]
entry = data["mcpServers"]["code-review-graph"]
assert entry["type"] == "stdio"
def test_install_kiro_preserves_existing_servers(self, tmp_path):
"""Existing mcpServers entries are preserved when adding code-review-graph."""
config_path = tmp_path / ".kiro" / "settings" / "mcp.json"
config_path.parent.mkdir(parents=True)
config_path.write_text(
json.dumps({"mcpServers": {"other-server": {"command": "other"}}}),
encoding="utf-8",
)
install_platform_configs(tmp_path, target="kiro")
data = json.loads(config_path.read_text())
assert "other-server" in data["mcpServers"]
assert "code-review-graph" in data["mcpServers"]
def test_install_kiro_no_duplicate(self, tmp_path):
"""Second install skips when code-review-graph already exists."""
install_platform_configs(tmp_path, target="kiro")
config_path = tmp_path / ".kiro" / "settings" / "mcp.json"
first_content = config_path.read_text()
install_platform_configs(tmp_path, target="kiro")
second_content = config_path.read_text()
assert first_content == second_content
data = json.loads(second_content)
assert list(data["mcpServers"].keys()).count("code-review-graph") == 1
def test_kiro_steering_file_written(self, tmp_path):
"""inject_platform_instructions creates .kiro/steering/code-review-graph.md."""
updated = inject_platform_instructions(tmp_path, target="kiro")
assert ".kiro/steering/code-review-graph.md" in updated
steering = tmp_path / ".kiro" / "steering" / "code-review-graph.md"
assert steering.exists()
content = steering.read_text()
assert _CLAUDE_MD_SECTION_MARKER in content
def test_kiro_steering_idempotent(self, tmp_path):
"""Running inject twice produces identical content."""
inject_platform_instructions(tmp_path, target="kiro")
first = (tmp_path / ".kiro" / "steering" / "code-review-graph.md").read_text()
inject_platform_instructions(tmp_path, target="kiro")
second = (tmp_path / ".kiro" / "steering" / "code-review-graph.md").read_text()
assert first == second
def test_kiro_included_in_all_when_detected(self, tmp_path):
"""install_platform_configs with target='all' includes Kiro when .kiro exists."""
(tmp_path / ".kiro").mkdir()
# Mock Path.home() to a dir without .kiro so only workspace detection fires
fake_home = tmp_path / "fakehome"
fake_home.mkdir()
with patch("code_review_graph.skills.Path.home", return_value=fake_home):
configured = install_platform_configs(tmp_path, target="all")
assert "Kiro" in configured
def test_kiro_workspace_detection(self, tmp_path):
"""Kiro detected when repo_root/.kiro exists even if ~/.kiro does not."""
(tmp_path / ".kiro").mkdir()
fake_home = tmp_path / "fakehome"
fake_home.mkdir()
with patch("code_review_graph.skills.Path.home", return_value=fake_home):
configured = install_platform_configs(tmp_path, target="all")
assert "Kiro" in configured
config_path = tmp_path / ".kiro" / "settings" / "mcp.json"
assert config_path.exists()
def test_kiro_dry_run(self, tmp_path):
"""dry_run=True does not create any files."""
configured = install_platform_configs(tmp_path, target="kiro", dry_run=True)
assert "Kiro" in configured
config_path = tmp_path / ".kiro" / "settings" / "mcp.json"
assert not config_path.exists()
class TestCopilotPlatform:
"""Tests for GitHub Copilot platform support."""
def test_copilot_platform_entry_exists(self):
"""PLATFORMS dict has a 'copilot' key with correct metadata."""
assert "copilot" in PLATFORMS
copilot = PLATFORMS["copilot"]
assert copilot["name"] == "GitHub Copilot"
assert copilot["key"] == "servers"
assert copilot["format"] == "object"
assert copilot["needs_type"] is True
def test_install_copilot_config(self, tmp_path):
"""install_platform_configs creates .vscode/mcp.json with 'servers' key."""
configured = install_platform_configs(tmp_path, target="copilot")
assert "GitHub Copilot" in configured
config_path = tmp_path / ".vscode" / "mcp.json"
assert config_path.exists()
data = json.loads(config_path.read_text())
assert "code-review-graph" in data["servers"]
entry = data["servers"]["code-review-graph"]
assert entry["type"] == "stdio"
assert "serve" in entry["args"]
def test_install_copilot_preserves_existing_servers(self, tmp_path):
"""Existing server entries are preserved when adding code-review-graph."""
config_path = tmp_path / ".vscode" / "mcp.json"
config_path.parent.mkdir(parents=True)
config_path.write_text(
json.dumps({"servers": {"other-server": {"command": "other"}}}),
encoding="utf-8",
)
install_platform_configs(tmp_path, target="copilot")
data = json.loads(config_path.read_text())
assert "other-server" in data["servers"]
assert "code-review-graph" in data["servers"]
def test_install_copilot_no_duplicate(self, tmp_path):
"""Second install skips when code-review-graph already exists."""
install_platform_configs(tmp_path, target="copilot")
config_path = tmp_path / ".vscode" / "mcp.json"
first_content = config_path.read_text()
install_platform_configs(tmp_path, target="copilot")
second_content = config_path.read_text()
assert first_content == second_content
data = json.loads(second_content)
assert list(data["servers"].keys()).count("code-review-graph") == 1
def test_copilot_instructions_file_written(self, tmp_path):
"""Copilot instructions use VS Code's auto-loaded workspace path."""
updated = inject_platform_instructions(tmp_path, target="copilot")
expected = ".github/instructions/code-review-graph.instructions.md"
assert updated == [expected]
instructions = tmp_path / expected
assert instructions.exists()
content = instructions.read_text()
assert _CLAUDE_MD_SECTION_MARKER in content
def test_copilot_instructions_idempotent(self, tmp_path):
"""Running inject twice produces identical content."""
instructions = (
tmp_path
/ ".github"
/ "instructions"
/ "code-review-graph.instructions.md"
)
inject_platform_instructions(tmp_path, target="copilot")
first = instructions.read_text()
inject_platform_instructions(tmp_path, target="copilot")
second = instructions.read_text()
assert first == second
def test_copilot_dry_run(self, tmp_path):
"""dry_run=True does not create any files."""
configured = install_platform_configs(tmp_path, target="copilot", dry_run=True)
assert "GitHub Copilot" in configured
config_path = tmp_path / ".vscode" / "mcp.json"
assert not config_path.exists()
def test_copilot_writes_only_copilot_instructions(self, tmp_path):
"""inject_platform_instructions with target='copilot' writes only copilot file."""
updated = inject_platform_instructions(tmp_path, target="copilot")
assert updated == [
".github/instructions/code-review-graph.instructions.md"
]
assert not (tmp_path / "AGENTS.md").exists()
assert not (tmp_path / "GEMINI.md").exists()
assert not (tmp_path / ".cursorrules").exists()
assert not (tmp_path / ".windsurfrules").exists()
assert not (tmp_path / "QODER.md").exists()
def test_copilot_included_in_all_when_detected(self, tmp_path):
"""Auto-detection requires the Copilot extension, not only VS Code."""
fake_home = tmp_path / "fakehome"
(fake_home / ".vscode" / "extensions" / "github.copilot-1.2.3").mkdir(
parents=True
)
with (
patch("code_review_graph.skills.Path.home", return_value=fake_home),
patch("code_review_graph.skills.platform.system", return_value="Unknown"),
patch("code_review_graph.skills.shutil.which", return_value=None),
):
configured = install_platform_configs(tmp_path, target="all")
assert "GitHub Copilot" in configured
config_path = tmp_path / ".vscode" / "mcp.json"
assert config_path.exists()
def test_copilot_detects_vscode_bundled_extension(self, tmp_path):
"""Current VS Code bundles Copilot under its application extensions."""
fake_home = tmp_path / "fakehome"
app_root = tmp_path / "vscode" / "resources" / "app"
code_cli = app_root / "bin" / "code"
code_cli.parent.mkdir(parents=True)
code_cli.write_text("", encoding="utf-8")
manifest = app_root / "extensions" / "copilot" / "package.json"
manifest.parent.mkdir(parents=True)
manifest.write_text(
json.dumps({"publisher": "GitHub", "name": "copilot-chat"}),
encoding="utf-8",
)
def _which(command):
return str(code_cli) if command == "code" else None
with (
patch("code_review_graph.skills.Path.home", return_value=fake_home),
patch("code_review_graph.skills.shutil.which", side_effect=_which),
):
assert _copilot_vscode_detected() is True
def test_copilot_not_detected_from_vscode_alone(self, tmp_path):
"""An unrelated VS Code install must not trigger Copilot configuration."""
fake_home = tmp_path / "fakehome"
(fake_home / ".vscode" / "extensions" / "ms-python.python-1.0.0").mkdir(
parents=True
)
with (
patch("code_review_graph.skills.Path.home", return_value=fake_home),
patch("code_review_graph.skills.platform.system", return_value="Unknown"),
patch("code_review_graph.skills.shutil.which", return_value=None),
):
configured = install_platform_configs(tmp_path, target="all")
assert "GitHub Copilot" not in configured
assert not (tmp_path / ".vscode" / "mcp.json").exists()
class TestCopilotCLIPlatform:
"""Tests for GitHub Copilot CLI platform support."""
def test_copilot_cli_platform_entry_exists(self):
"""Copilot CLI uses the schema accepted by the released client."""
assert "copilot-cli" in PLATFORMS
copilot_cli = PLATFORMS["copilot-cli"]
assert copilot_cli["name"] == "GitHub Copilot CLI"
assert copilot_cli["key"] == "mcpServers"
assert copilot_cli["legacy_keys"] == ("servers",)
assert copilot_cli["format"] == "object"
assert copilot_cli["needs_type"] is True
assert copilot_cli["server_type"] == "local"
assert copilot_cli["entry_fields"] == {"tools": ["*"]}
def test_install_copilot_cli_config(self, tmp_path):
"""Install writes the released Copilot CLI MCP contract."""
fake_home = tmp_path / "fakehome"
(fake_home / ".copilot").mkdir(parents=True)
config_path = fake_home / ".copilot" / "mcp-config.json"
with patch.dict(
PLATFORMS,
{
"copilot-cli": {
**PLATFORMS["copilot-cli"],
"config_path": lambda root: config_path,
"detect": lambda: True,
},
},
):
configured = install_platform_configs(tmp_path, target="copilot-cli")
assert "GitHub Copilot CLI" in configured
assert config_path.exists()
data = json.loads(config_path.read_text())
assert "servers" not in data
entry = data["mcpServers"]["code-review-graph"]
assert entry["type"] == "local"
assert entry["tools"] == ["*"]
assert entry["cwd"] == str(tmp_path)
assert "serve" in entry["args"]
def test_install_copilot_cli_preserves_existing_servers(self, tmp_path):
"""Existing server entries are preserved when adding code-review-graph."""
fake_home = tmp_path / "fakehome"
config_path = fake_home / ".copilot" / "mcp-config.json"
config_path.parent.mkdir(parents=True)
config_path.write_text(
json.dumps(
{
"mcpServers": {"other-server": {"command": "other"}},
"theme": "dark",
}
),
encoding="utf-8",
)
with patch.dict(
PLATFORMS,
{
"copilot-cli": {
**PLATFORMS["copilot-cli"],
"config_path": lambda root: config_path,
"detect": lambda: True,
},
},
):
install_platform_configs(tmp_path, target="copilot-cli")
data = json.loads(config_path.read_text())
assert data["mcpServers"]["other-server"] == {"command": "other"}
assert "code-review-graph" in data["mcpServers"]
assert data["theme"] == "dark"
def test_install_copilot_cli_migrates_empty_legacy_entry(self, tmp_path):
"""An empty generated legacy entry must not survive migration."""
config_path = tmp_path / "fakehome" / ".copilot" / "mcp-config.json"
config_path.parent.mkdir(parents=True)
config_path.write_text(
json.dumps(
{
"mcpServers": {
"current-server": {"command": "keep-current"},
},
"servers": {
"code-review-graph": {},
"legacy-server": {"command": "keep-legacy"},
},
"theme": "dark",
}
),
encoding="utf-8",
)
with patch.dict(
PLATFORMS,
{
"copilot-cli": {
**PLATFORMS["copilot-cli"],
"config_path": lambda root: config_path,
"detect": lambda: True,
},
},
):
install_platform_configs(tmp_path, target="copilot-cli")
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["mcpServers"]["current-server"] == {
"command": "keep-current",
}
entry = data["mcpServers"]["code-review-graph"]
assert entry["type"] == "local"
assert entry["tools"] == ["*"]
assert data["servers"] == {
"legacy-server": {"command": "keep-legacy"},
}
assert data["theme"] == "dark"
def test_install_copilot_cli_drops_emptied_legacy_key(self, tmp_path):
"""Migration removes the obsolete container when no user entries remain."""
config_path = tmp_path / "fakehome" / ".copilot" / "mcp-config.json"
config_path.parent.mkdir(parents=True)
config_path.write_text(
json.dumps({"servers": {"code-review-graph": {}}}),
encoding="utf-8",
)
with patch.dict(
PLATFORMS,
{
"copilot-cli": {
**PLATFORMS["copilot-cli"],
"config_path": lambda root: config_path,
"detect": lambda: True,
},
},
):
install_platform_configs(tmp_path, target="copilot-cli")
data = json.loads(config_path.read_text(encoding="utf-8"))
assert "servers" not in data
assert "code-review-graph" in data["mcpServers"]
def test_install_copilot_cli_reinstall_is_byte_for_byte_idempotent(
self, tmp_path
):
"""A second install must not rewrite an already valid client config."""
config_path = tmp_path / "fakehome" / ".copilot" / "mcp-config.json"
with patch.dict(
PLATFORMS,
{
"copilot-cli": {
**PLATFORMS["copilot-cli"],
"config_path": lambda root: config_path,
"detect": lambda: True,
},
},
):
install_platform_configs(tmp_path, target="copilot-cli")
first = config_path.read_bytes()
install_platform_configs(tmp_path, target="copilot-cli")
second = config_path.read_bytes()
assert second == first
def test_copilot_cli_writes_only_copilot_instructions(self, tmp_path):
"""Copilot CLI injection writes its GitHub instruction file."""
updated = inject_platform_instructions(tmp_path, target="copilot-cli")
expected = ".github/instructions/code-review-graph.instructions.md"
assert updated == [expected]
instructions = tmp_path / expected
assert instructions.exists()
content = instructions.read_text()
assert _CLAUDE_MD_SECTION_MARKER in content
def test_copilot_cli_reinstall_migrates_generated_legacy_instruction(
self, tmp_path
):
"""Reinstall removes only CRG content from the superseded path."""
legacy = tmp_path / ".github" / "code-review-graph.instruction.md"
legacy.parent.mkdir(parents=True)
legacy.write_text(
"# User notes\n\n" + skills_module._COPILOT_SECTION,
encoding="utf-8",
)
inject_platform_instructions(tmp_path, target="copilot-cli")
assert legacy.read_text(encoding="utf-8") == "# User notes\n"
current = (
tmp_path
/ ".github"
/ "instructions"
/ "code-review-graph.instructions.md"
)
assert current.exists()
def test_copilot_cli_reinstall_deletes_generated_only_legacy_instruction(
self, tmp_path
):
"""A legacy file containing only the generated section is removed."""
legacy = tmp_path / ".github" / "code-review-graph.instruction.md"
legacy.parent.mkdir(parents=True)
legacy.write_text(skills_module._COPILOT_SECTION, encoding="utf-8")
inject_platform_instructions(tmp_path, target="copilot-cli")
assert not legacy.exists()
def test_copilot_cli_reinstall_leaves_user_legacy_instruction_untouched(
self, tmp_path
):
"""A user-authored file without the CRG marker is never rewritten."""
legacy = tmp_path / ".github" / "code-review-graph.instruction.md"
legacy.parent.mkdir(parents=True)
legacy.write_text("# User instructions\n", encoding="utf-8")
inject_platform_instructions(tmp_path, target="copilot-cli")
assert legacy.read_text(encoding="utf-8") == "# User instructions\n"
class TestDetectServeCommand:
"""Tests for _detect_serve_command() and its helpers."""
# ------------------------------------------------------------------
# _in_poetry_project() unit tests
# ------------------------------------------------------------------
def test_in_poetry_project_via_poetry_active(self, monkeypatch):
"""POETRY_ACTIVE=1 signals a poetry shell session."""
monkeypatch.setenv("POETRY_ACTIVE", "1")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
assert _in_poetry_project() is True
def test_in_poetry_project_via_virtual_env(self, monkeypatch):
"""VIRTUAL_ENV containing 'pypoetry' signals a poetry run session."""
monkeypatch.delenv("POETRY_ACTIVE", raising=False)
monkeypatch.setenv("VIRTUAL_ENV", "/home/user/.cache/pypoetry/virtualenvs/proj-xxx")
assert _in_poetry_project() is True
def test_in_poetry_project_false_for_plain_venv(self, monkeypatch):
"""A plain venv (no pypoetry in path) is not treated as poetry."""
monkeypatch.delenv("POETRY_ACTIVE", raising=False)
monkeypatch.setenv("VIRTUAL_ENV", "/home/user/myproject/.venv")
assert _in_poetry_project() is False
def test_in_poetry_project_false_when_nothing_set(self, monkeypatch):
"""No env vars → not in a poetry project."""
monkeypatch.delenv("POETRY_ACTIVE", raising=False)
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
assert _in_poetry_project() is False
# ------------------------------------------------------------------
# _detect_serve_command() integration tests
# ------------------------------------------------------------------
def test_poetry_active_returns_poetry_run(self, monkeypatch):
"""POETRY_ACTIVE=1 (poetry shell) → 'poetry run' invocation."""
monkeypatch.setenv("POETRY_ACTIVE", "1")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(
"code_review_graph.skills.shutil.which",
lambda x: "/usr/bin/poetry" if x == "poetry" else None,
)
cmd, args = _detect_serve_command()
assert cmd == "poetry"
assert args == ["run", "code-review-graph", "serve"]
def test_virtual_env_pypoetry_returns_poetry_run(self, monkeypatch):
"""VIRTUAL_ENV with 'pypoetry' (poetry run) → 'poetry run' invocation."""
monkeypatch.delenv("POETRY_ACTIVE", raising=False)
monkeypatch.setenv("VIRTUAL_ENV", "/home/user/.cache/pypoetry/virtualenvs/proj-abc123")
monkeypatch.setattr(
"code_review_graph.skills.shutil.which",
lambda x: "/usr/bin/poetry" if x == "poetry" else None,
)
cmd, args = _detect_serve_command()
assert cmd == "poetry"
assert args == ["run", "code-review-graph", "serve"]
def test_poetry_env_without_poetry_on_path_falls_through(self, monkeypatch):
"""If poetry venv is detected but poetry binary is missing, fall through."""
monkeypatch.setenv("POETRY_ACTIVE", "1")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.delenv("UV_PROJECT_ENVIRONMENT", raising=False)
monkeypatch.setattr("code_review_graph.skills._in_uv_project", lambda: False)
# poetry not on PATH → should fall through to uvx
monkeypatch.setattr(
"code_review_graph.skills.shutil.which",
lambda x: "/usr/bin/uvx" if x == "uvx" else None,
)
cmd, _ = _detect_serve_command()
assert cmd == "uvx"
def test_uv_project_env_returns_uv_run(self, monkeypatch):
"""UV_PROJECT_ENVIRONMENT set + uv on PATH → 'uv run' invocation."""
monkeypatch.delenv("POETRY_ACTIVE", raising=False)
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setenv("UV_PROJECT_ENVIRONMENT", "/some/.venv")
monkeypatch.setattr(
"code_review_graph.skills.shutil.which",
lambda x: "/usr/bin/uv" if x == "uv" else None,
)
cmd, args = _detect_serve_command()
assert cmd == "uv"
assert args == ["run", "code-review-graph", "serve"]
def test_uv_lock_detection_returns_uv_run(self, monkeypatch, tmp_path):
"""uv.lock alongside sys.executable → detected as a uv project."""
monkeypatch.delenv("POETRY_ACTIVE", raising=False)
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.delenv("UV_PROJECT_ENVIRONMENT", raising=False)
venv = tmp_path / ".venv" / "bin"
venv.mkdir(parents=True)
(tmp_path / "uv.lock").write_text("")
fake_python = venv / "python"
fake_python.write_text("")
monkeypatch.setattr("code_review_graph.skills.sys.executable", str(fake_python))
monkeypatch.setattr(
"code_review_graph.skills.shutil.which",
lambda x: "/usr/bin/uv" if x == "uv" else None,
)
assert _in_uv_project() is True
cmd, args = _detect_serve_command()
assert cmd == "uv"
assert args == ["run", "code-review-graph", "serve"]
def test_uvx_fallback(self, monkeypatch):
"""Not in Poetry/uv but uvx available → use uvx (original behaviour)."""
monkeypatch.delenv("POETRY_ACTIVE", raising=False)
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.delenv("UV_PROJECT_ENVIRONMENT", raising=False)
monkeypatch.setattr("code_review_graph.skills._in_uv_project", lambda: False)
monkeypatch.setattr(
"code_review_graph.skills.shutil.which",
lambda x: "/usr/bin/uvx" if x == "uvx" else None,
)
cmd, args = _detect_serve_command()
assert cmd == "uvx"
assert args == ["code-review-graph", "serve"]
def test_sys_executable_fallback(self, monkeypatch):
"""Nothing else available → fall back to sys.executable -m."""
monkeypatch.delenv("POETRY_ACTIVE", raising=False)
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.delenv("UV_PROJECT_ENVIRONMENT", raising=False)
monkeypatch.setattr("code_review_graph.skills._in_uv_project", lambda: False)
monkeypatch.setattr("code_review_graph.skills.shutil.which", lambda _: None)
cmd, args = _detect_serve_command()
assert cmd == sys.executable
assert args == ["-m", "code_review_graph", "serve"]
def test_poetry_takes_priority_over_uv(self, monkeypatch):
"""Poetry detection wins even when UV_PROJECT_ENVIRONMENT is also set."""
monkeypatch.setenv("POETRY_ACTIVE", "1")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setenv("UV_PROJECT_ENVIRONMENT", "/some/.venv")
monkeypatch.setattr(
"code_review_graph.skills.shutil.which",
lambda x: "/usr/bin/poetry" if x == "poetry" else None,
)
cmd, _ = _detect_serve_command()
assert cmd == "poetry"
def test_in_uv_project_false_without_lockfile(self, monkeypatch, tmp_path):
"""_in_uv_project returns False when no uv.lock in ancestor dirs."""
fake_python = tmp_path / "bin" / "python"
fake_python.parent.mkdir(parents=True)
fake_python.write_text("")
monkeypatch.setattr("code_review_graph.skills.sys.executable", str(fake_python))
monkeypatch.setattr("code_review_graph.skills.Path.home", staticmethod(lambda: tmp_path))
assert _in_uv_project() is False
class TestOpenCodePluginContent:
"""Tests for _opencode_plugin_content()."""
def test_returns_non_empty_string(self):
content = _opencode_plugin_content()
assert isinstance(content, str)
assert len(content) > 100
def test_has_plugin_type_import(self):
content = _opencode_plugin_content()
assert "import type" in content
assert "@opencode-ai/plugin" in content
def test_has_default_export(self):
content = _opencode_plugin_content()
assert "export default" in content
def test_hooks_file_edited_event(self):
content = _opencode_plugin_content()
assert '"file.edited"' in content
assert "code-review-graph update --skip-flows" in content
def test_hooks_session_created_event(self):
content = _opencode_plugin_content()
assert '"session.created"' in content
assert "code-review-graph status" in content
def test_hooks_tool_execute_before_event(self):
content = _opencode_plugin_content()
assert '"tool.execute.before"' in content
assert "code-review-graph detect-changes --brief" in content
def test_has_git_commit_detection(self):
"""Pre-commit hook should match git commit commands."""
content = _opencode_plugin_content()
assert "git" in content
assert "commit" in content
def test_all_handlers_have_try_catch(self):
"""Every event handler must use try/catch for graceful failure."""
content = _opencode_plugin_content()
# Count the three event registrations and ensure catch blocks
assert content.count("} catch") >= 3
class TestInstallOpenCodePlugin:
"""Tests for install_opencode_plugin()."""
def test_creates_plugin_file(self, tmp_path):
with patch("code_review_graph.skills.Path.home", return_value=tmp_path):
result = install_opencode_plugin()
plugin_path = tmp_path / ".config" / "opencode" / "plugins" / "crg-plugin.ts"
assert plugin_path.exists()
assert result == plugin_path
def test_plugin_file_has_correct_content(self, tmp_path):
with patch("code_review_graph.skills.Path.home", return_value=tmp_path):
result = install_opencode_plugin()
content = result.read_text(encoding="utf-8")
assert "export default" in content
assert "file.edited" in content
def test_creates_parent_directories(self, tmp_path):
with patch("code_review_graph.skills.Path.home", return_value=tmp_path):
install_opencode_plugin()
plugins_dir = tmp_path / ".config" / "opencode" / "plugins"
assert plugins_dir.is_dir()
def test_overwrites_existing_plugin(self, tmp_path):
plugins_dir = tmp_path / ".config" / "opencode" / "plugins"
plugins_dir.mkdir(parents=True)
old_plugin = plugins_dir / "crg-plugin.ts"
old_plugin.write_text("// old version")
with patch("code_review_graph.skills.Path.home", return_value=tmp_path):
install_opencode_plugin()
content = old_plugin.read_text()
assert "// old version" not in content
assert "export default" in content
def test_idempotent(self, tmp_path):
with patch("code_review_graph.skills.Path.home", return_value=tmp_path):
install_opencode_plugin()
result = install_opencode_plugin()
content = result.read_text()
assert "export default" in content
# Only one default export in the file
assert content.count("export default") == 1
def test_plugin_is_typescript(self, tmp_path):
with patch("code_review_graph.skills.Path.home", return_value=tmp_path):
result = install_opencode_plugin()
assert result.suffix == ".ts"
def test_preserves_other_plugins(self, tmp_path):
plugins_dir = tmp_path / ".config" / "opencode" / "plugins"
plugins_dir.mkdir(parents=True)
other_plugin = plugins_dir / "other-plugin.ts"
other_plugin.write_text("// other plugin")
with patch("code_review_graph.skills.Path.home", return_value=tmp_path):
install_opencode_plugin()
assert other_plugin.exists()
assert other_plugin.read_text() == "// other plugin"
def test_file_is_utf8(self, tmp_path):
with patch("code_review_graph.skills.Path.home", return_value=tmp_path):
result = install_opencode_plugin()
# Should be readable as UTF-8 without errors
content = result.read_text(encoding="utf-8")
assert len(content) > 0
class TestInstallConfigDataLoss:
"""Regression tests for #344: ``install_platform_configs`` must never
destroy a user's existing platform config. Two residual bugs remained
on main even after the JSONC-stripping fix:
* a top-level JSON *array* hit ``existing.get(...)`` and crashed with
AttributeError before writing;
* an *empty* settings file was mis-flagged "unparseable" and skipped,
so a fresh install on an empty file silently did nothing.
"""
def _run_zed(self, settings_path: Path, root: Path):
with patch.dict(
PLATFORMS,
{
"zed": {
**PLATFORMS["zed"],
"config_path": lambda r: settings_path,
"detect": lambda: True,
},
},
):
return install_platform_configs(root, target="zed")
def _run_continue(self, settings_path: Path, root: Path):
with patch.dict(
PLATFORMS,
{
"continue": {
**PLATFORMS["continue"],
"config_path": lambda r: settings_path,
"detect": lambda: True,
},
},
):
return install_platform_configs(root, target="continue")
def test_malformed_json_is_preserved_not_overwritten(self, tmp_path, capsys):
settings = tmp_path / "zed" / "settings.json"
settings.parent.mkdir(parents=True)
original = "{ this is not valid json }\n"
settings.write_text(original, encoding="utf-8")
configured = self._run_zed(settings, tmp_path)
assert "Zed" not in configured
assert settings.read_text(encoding="utf-8") == original
assert "unparseable" in capsys.readouterr().out
def test_top_level_array_does_not_crash_and_is_preserved(self, tmp_path, capsys):
"""The actual residual bug: a top-level array crashed install with
``AttributeError: 'list' object has no attribute 'get'``."""
settings = tmp_path / "zed" / "settings.json"
settings.parent.mkdir(parents=True)
original = '["not", "an", "object"]'
settings.write_text(original, encoding="utf-8")
# Must not raise.
configured = self._run_zed(settings, tmp_path)
assert "Zed" not in configured
assert settings.read_text(encoding="utf-8") == original
out = capsys.readouterr().out
assert "not a top-level object" in out
def test_empty_file_is_treated_as_fresh_config(self, tmp_path):
"""An empty settings.json is a valid empty config, not a parse
failure — install should write a fresh config rather than skip."""
settings = tmp_path / "zed" / "settings.json"
settings.parent.mkdir(parents=True)
settings.write_text("", encoding="utf-8")
configured = self._run_zed(settings, tmp_path)
assert "Zed" in configured
data = json.loads(settings.read_text(encoding="utf-8"))
assert "code-review-graph" in data["context_servers"]
def test_jsonc_comments_still_supported(self, tmp_path):
"""Guard: the empty-file / array checks must not regress main's
JSONC support — a comment-bearing Zed config must still merge."""
settings = tmp_path / "zed" / "settings.json"
settings.parent.mkdir(parents=True)
settings.write_text(
"{\n"
' // user theme preference\n'
' "theme": "One Dark",\n'
"}\n",
encoding="utf-8",
)
configured = self._run_zed(settings, tmp_path)
assert "Zed" in configured
data = json.loads(settings.read_text(encoding="utf-8"))
# User's existing setting preserved AND our server added.
assert data["theme"] == "One Dark"
assert "code-review-graph" in data["context_servers"]
def test_array_platform_preserves_wrong_typed_server_collection(
self, tmp_path, capsys
):
config = tmp_path / ".continue" / "config.json"
config.parent.mkdir(parents=True)
original = '{\n "mcpServers": {"legacy": "keep-me"}\n}\n'
config.write_text(original, encoding="utf-8")
configured = self._run_continue(config, tmp_path)
assert "Continue" not in configured
assert config.read_text(encoding="utf-8") == original
out = capsys.readouterr().out
assert "mcpServers" in out
assert "expected a JSON array" in out
assert "skipping to avoid data loss" in out
def test_object_platform_preserves_wrong_typed_server_collection(
self, tmp_path, capsys
):
settings = tmp_path / "zed" / "settings.json"
settings.parent.mkdir(parents=True)
original = '{\n "context_servers": ["legacy-server"]\n}\n'
settings.write_text(original, encoding="utf-8")
configured = self._run_zed(settings, tmp_path)
assert "Zed" not in configured
assert settings.read_text(encoding="utf-8") == original
out = capsys.readouterr().out
assert "context_servers" in out
assert "expected a JSON object" in out
assert "skipping to avoid data loss" in out
class TestGeneratedHooksGuardGitRepo:
"""Regression coverage for #312: generated Claude Code hooks must guard
the ``update`` / ``status`` commands behind a git-repo check so that, in
a monorepo whose workspace root has no ``.git``, the PostToolUse hook
no-ops silently instead of erroring on every tool call.
"""
def test_post_tool_use_command_guarded_by_git_check(self):
config = generate_hooks_config(Path("/repo"))
cmd = config["hooks"]["PostToolUse"][0]["hooks"][0]["command"]
# Must short-circuit on the git check before calling update.
assert "git rev-parse --git-dir" in cmd
idx_guard = cmd.index("git rev-parse --git-dir")
idx_update = cmd.index("code-review-graph update")
assert idx_guard < idx_update, "git guard must precede the update call"
def test_session_start_command_guarded_by_git_check(self):
config = generate_hooks_config(Path("/repo"))
cmd = config["hooks"]["SessionStart"][0]["hooks"][0]["command"]
assert "git rev-parse --git-dir" in cmd
idx_guard = cmd.index("git rev-parse --git-dir")
idx_status = cmd.index("code-review-graph status")
assert idx_guard < idx_status
class TestInstallSkillsRespectTargetPlatform:
"""Regression coverage for #350: ``install --platform cursor`` must NOT
generate Claude Code skills under ``.claude/skills/`` — that directory
is only read by Claude Code, and creating it for other platforms
confused users into thinking the tool wrote Claude config unprompted.
"""
def _run_install(self, tmp_path, platform: str) -> bool:
import argparse
from code_review_graph import cli as crg_cli
args = argparse.Namespace(
command="install",
repo=str(tmp_path),
platform=platform,
yes=True,
dry_run=False,
no_skills=False,
no_hooks=True,
no_instructions=True,
)
with patch("builtins.input", return_value="n"):
with patch("code_review_graph.skills.Path.home", return_value=tmp_path):
crg_cli._handle_init(args)
return (tmp_path / ".claude" / "skills").is_dir()
def test_cursor_install_does_not_create_claude_skills(self, tmp_path):
assert self._run_install(tmp_path, "cursor") is False
def test_windsurf_install_does_not_create_claude_skills(self, tmp_path):
assert self._run_install(tmp_path, "windsurf") is False
def test_claude_install_creates_skills(self, tmp_path):
assert self._run_install(tmp_path, "claude") is True
def test_all_target_creates_skills(self, tmp_path):
assert self._run_install(tmp_path, "all") is True
class TestNonAsciiConfigPreservation:
"""#497: json.dumps(..., indent=2) defaults to ensure_ascii=True, so any
non-ASCII content round-tripped through these config writers (a repo path,
or a pre-existing custom field) gets serialized as literal \\uXXXX escapes
instead of UTF-8. Technically valid JSON, but some MCP hosts / process
launchers don't decode \\uXXXX correctly when consuming these files directly
(see #497) — write real UTF-8 instead.
"""
NON_ASCII = "基于STM32的项目"
def test_install_platform_configs_preserves_non_ascii_cwd(self, tmp_path):
repo_root = tmp_path / self.NON_ASCII
repo_root.mkdir()
install_platform_configs(repo_root, target="claude")
raw = (repo_root / ".mcp.json").read_text(encoding="utf-8")
assert self.NON_ASCII in raw
assert "\\u" not in raw
def test_merge_hooks_into_settings_preserves_non_ascii_field(self, tmp_path):
settings_dir = tmp_path / ".claude"
settings_dir.mkdir()
(settings_dir / "settings.json").write_text(
json.dumps({"customSetting": self.NON_ASCII}), encoding="utf-8",
)
install_hooks(tmp_path, platform="claude")
raw = (settings_dir / "settings.json").read_text(encoding="utf-8")
assert self.NON_ASCII in raw
assert "\\u" not in raw
def test_install_codex_hooks_preserves_non_ascii_field(self, tmp_path, monkeypatch):
monkeypatch.setattr("code_review_graph.skills.Path.home", lambda: tmp_path)
codex_dir = tmp_path / ".codex"
codex_dir.mkdir()
(codex_dir / "hooks.json").write_text(
json.dumps({"customSetting": self.NON_ASCII}), encoding="utf-8",
)
install_codex_hooks(tmp_path / "repo")
raw = (codex_dir / "hooks.json").read_text(encoding="utf-8")
assert self.NON_ASCII in raw
assert "\\u" not in raw
def test_install_gemini_cli_hooks_preserves_non_ascii_field(self, tmp_path):
settings_dir = tmp_path / ".gemini"
settings_dir.mkdir()
(settings_dir / "settings.json").write_text(
json.dumps({"customSetting": self.NON_ASCII}), encoding="utf-8",
)
install_gemini_cli_hooks(tmp_path)
raw = (settings_dir / "settings.json").read_text(encoding="utf-8")
assert self.NON_ASCII in raw
assert "\\u" not in raw
def test_install_cursor_hooks_preserves_non_ascii_field(self, tmp_path, monkeypatch):
monkeypatch.setattr("code_review_graph.skills.Path.home", lambda: tmp_path)
cursor_dir = tmp_path / ".cursor"
cursor_dir.mkdir()
(cursor_dir / "hooks.json").write_text(
json.dumps({"customSetting": self.NON_ASCII}), encoding="utf-8",
)
install_cursor_hooks()
raw = (cursor_dir / "hooks.json").read_text(encoding="utf-8")
assert self.NON_ASCII in raw
assert "\\u" not in raw