- Move Claude Code hooks under src/quality/hooks (rename modules) - Add a project-local installer for Claude Code hooks - Introduce internal_duplicate_detector and code_quality_guard - Update tests to reference new module paths and guard API - Bump package version to 0.1.1 and adjust packaging
593 lines
19 KiB
Python
593 lines
19 KiB
Python
"""Comprehensive test suite covering all hook interaction scenarios."""
|
|
|
|
# ruff: noqa: SLF001
|
|
# pyright: reportPrivateUsage=false, reportPrivateImportUsage=false, reportPrivateLocalImportUsage=false, reportUnusedCallResult=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownLambdaType=false, reportUnknownMemberType=false
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
from tempfile import gettempdir
|
|
|
|
import pytest
|
|
|
|
from quality.hooks import code_quality_guard as guard
|
|
|
|
|
|
class TestProjectStructureVariations:
|
|
"""Test different project structure layouts."""
|
|
|
|
def test_flat_layout_no_src(self) -> None:
|
|
"""Project without src/ directory."""
|
|
root = Path.home() / f"test_flat_{os.getpid()}"
|
|
try:
|
|
root.mkdir()
|
|
(root / ".venv/bin").mkdir(parents=True)
|
|
(root / "pyproject.toml").touch()
|
|
|
|
test_file = root / "main.py"
|
|
test_file.write_text("# test")
|
|
|
|
# Should find project root
|
|
found_root = guard._find_project_root(str(test_file))
|
|
assert found_root == root
|
|
|
|
# Should create .tmp in root
|
|
tmp_dir = guard._get_project_tmp_dir(str(test_file))
|
|
assert tmp_dir == root / ".tmp"
|
|
finally:
|
|
import shutil
|
|
if root.exists():
|
|
shutil.rmtree(root)
|
|
|
|
def test_src_layout(self) -> None:
|
|
"""Project with src/ directory."""
|
|
root = Path.home() / f"test_src_{os.getpid()}"
|
|
try:
|
|
(root / "src/package").mkdir(parents=True)
|
|
(root / ".venv/bin").mkdir(parents=True)
|
|
(root / "pyproject.toml").touch()
|
|
|
|
test_file = root / "src/package/module.py"
|
|
test_file.write_text("# test")
|
|
|
|
found_root = guard._find_project_root(str(test_file))
|
|
assert found_root == root
|
|
|
|
venv_bin = guard._get_project_venv_bin(str(test_file))
|
|
assert venv_bin == root / ".venv/bin"
|
|
finally:
|
|
import shutil
|
|
if root.exists():
|
|
shutil.rmtree(root)
|
|
|
|
def test_nested_projects_uses_closest(self) -> None:
|
|
"""Nested projects should use closest .venv."""
|
|
outer = Path.home() / f"test_outer_{os.getpid()}"
|
|
try:
|
|
# Outer project
|
|
(outer / ".venv/bin").mkdir(parents=True)
|
|
(outer / ".git").mkdir()
|
|
|
|
# Inner project
|
|
inner = outer / "subproject"
|
|
(inner / ".venv/bin").mkdir(parents=True)
|
|
(inner / "pyproject.toml").touch()
|
|
|
|
test_file = inner / "main.py"
|
|
test_file.write_text("# test")
|
|
|
|
# Should find inner project root
|
|
found_root = guard._find_project_root(str(test_file))
|
|
assert found_root == inner
|
|
|
|
# Should use inner venv
|
|
venv_bin = guard._get_project_venv_bin(str(test_file))
|
|
assert venv_bin == inner / ".venv/bin"
|
|
finally:
|
|
import shutil
|
|
if outer.exists():
|
|
shutil.rmtree(outer)
|
|
|
|
def test_no_project_markers_uses_parent(self) -> None:
|
|
"""File with no project markers searches up to filesystem root."""
|
|
root = Path.home() / f"test_nomarkers_{os.getpid()}"
|
|
try:
|
|
(root / "subdir").mkdir(parents=True)
|
|
test_file = root / "subdir/file.py"
|
|
test_file.write_text("# test")
|
|
|
|
# With no markers, searches all the way up
|
|
# (may find .git in home directory or elsewhere)
|
|
found_root = guard._find_project_root(str(test_file))
|
|
# Should at least not crash
|
|
assert isinstance(found_root, Path)
|
|
finally:
|
|
import shutil
|
|
if root.exists():
|
|
shutil.rmtree(root)
|
|
|
|
def test_deeply_nested_file(self) -> None:
|
|
"""File deeply nested finds root correctly."""
|
|
root = Path.home() / f"test_deep_{os.getpid()}"
|
|
try:
|
|
deep = root / "a/b/c/d/e/f"
|
|
deep.mkdir(parents=True)
|
|
(root / ".git").mkdir()
|
|
|
|
test_file = deep / "module.py"
|
|
test_file.write_text("# test")
|
|
|
|
found_root = guard._find_project_root(str(test_file))
|
|
assert found_root == root
|
|
finally:
|
|
import shutil
|
|
if root.exists():
|
|
shutil.rmtree(root)
|
|
|
|
|
|
class TestConfigurationInheritance:
|
|
"""Test configuration file inheritance."""
|
|
|
|
def test_pyrightconfig_in_root(self) -> None:
|
|
"""pyrightconfig.json at project root is found."""
|
|
root = Path.home() / f"test_pyright_{os.getpid()}"
|
|
try:
|
|
(root / "src").mkdir(parents=True)
|
|
(root / ".venv/bin").mkdir(parents=True)
|
|
|
|
config = {"reportUnknownMemberType": False}
|
|
(root / "pyrightconfig.json").write_text(json.dumps(config))
|
|
|
|
test_file = root / "src/mod.py"
|
|
test_file.write_text("# test")
|
|
|
|
found_root = guard._find_project_root(str(test_file))
|
|
assert found_root == root
|
|
assert (found_root / "pyrightconfig.json").exists()
|
|
finally:
|
|
import shutil
|
|
if root.exists():
|
|
shutil.rmtree(root)
|
|
|
|
def test_pyproject_toml_as_marker(self) -> None:
|
|
"""pyproject.toml serves as project marker."""
|
|
root = Path.home() / f"test_pyproj_{os.getpid()}"
|
|
try:
|
|
root.mkdir()
|
|
(root / "pyproject.toml").write_text("[tool.mypy]\n")
|
|
|
|
test_file = root / "main.py"
|
|
test_file.write_text("# test")
|
|
|
|
found_root = guard._find_project_root(str(test_file))
|
|
assert found_root == root
|
|
finally:
|
|
import shutil
|
|
if root.exists():
|
|
shutil.rmtree(root)
|
|
|
|
def test_gitignore_updated_for_tmp(self) -> None:
|
|
""".tmp/ is added to .gitignore if not present."""
|
|
root = Path.home() / f"test_gitignore_{os.getpid()}"
|
|
try:
|
|
root.mkdir()
|
|
(root / "pyproject.toml").touch()
|
|
(root / ".gitignore").write_text("*.pyc\n__pycache__/\n")
|
|
|
|
test_file = root / "main.py"
|
|
test_file.write_text("# test")
|
|
|
|
tmp_dir = guard._get_project_tmp_dir(str(test_file))
|
|
assert tmp_dir.exists()
|
|
|
|
gitignore_content = (root / ".gitignore").read_text()
|
|
assert ".tmp/" in gitignore_content
|
|
finally:
|
|
import shutil
|
|
if root.exists():
|
|
shutil.rmtree(root)
|
|
|
|
def test_gitignore_not_modified_if_tmp_present(self) -> None:
|
|
""".gitignore not modified if .tmp already present."""
|
|
root = Path.home() / f"test_gitignore2_{os.getpid()}"
|
|
try:
|
|
root.mkdir()
|
|
(root / "pyproject.toml").touch()
|
|
original = "*.pyc\n.tmp/\n"
|
|
(root / ".gitignore").write_text(original)
|
|
|
|
test_file = root / "main.py"
|
|
test_file.write_text("# test")
|
|
|
|
_ = guard._get_project_tmp_dir(str(test_file))
|
|
|
|
# Should not have been modified
|
|
assert (root / ".gitignore").read_text() == original
|
|
finally:
|
|
import shutil
|
|
if root.exists():
|
|
shutil.rmtree(root)
|
|
|
|
|
|
class TestVirtualEnvironmentEdgeCases:
|
|
"""Test virtual environment edge cases."""
|
|
|
|
def test_venv_missing_fallback_to_claude_scripts(self) -> None:
|
|
"""No .venv in project falls back."""
|
|
root = Path.home() / f"test_novenv_{os.getpid()}"
|
|
try:
|
|
root.mkdir()
|
|
(root / "pyproject.toml").touch()
|
|
test_file = root / "main.py"
|
|
test_file.write_text("# test")
|
|
|
|
venv_bin = guard._get_project_venv_bin(str(test_file))
|
|
|
|
# Should not be in the test project
|
|
assert str(root) not in str(venv_bin)
|
|
# Should be a valid path
|
|
assert venv_bin.name == "bin"
|
|
finally:
|
|
import shutil
|
|
if root.exists():
|
|
shutil.rmtree(root)
|
|
|
|
def test_venv_exists_but_no_bin(self) -> None:
|
|
""".venv exists but bin/ directory missing."""
|
|
root = Path.home() / f"test_nobin_{os.getpid()}"
|
|
try:
|
|
(root / ".venv").mkdir(parents=True)
|
|
(root / "pyproject.toml").touch()
|
|
test_file = root / "main.py"
|
|
test_file.write_text("# test")
|
|
|
|
venv_bin = guard._get_project_venv_bin(str(test_file))
|
|
|
|
# Should fallback since bin/ doesn't exist in project
|
|
assert str(root) not in str(venv_bin)
|
|
assert venv_bin.name == "bin"
|
|
finally:
|
|
import shutil
|
|
if root.exists():
|
|
shutil.rmtree(root)
|
|
|
|
def test_pythonpath_not_set_without_src(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""PYTHONPATH not set when src/ doesn't exist."""
|
|
root = Path.home() / f"test_nosrc_{os.getpid()}"
|
|
try:
|
|
(root / ".venv/bin").mkdir(parents=True)
|
|
(root / "pyproject.toml").touch()
|
|
tool = root / ".venv/bin/basedpyright"
|
|
tool.write_text("#!/bin/bash\necho fake")
|
|
tool.chmod(0o755)
|
|
|
|
test_file = root / "main.py"
|
|
test_file.write_text("# test")
|
|
|
|
captured_env: dict[str, str] = {}
|
|
|
|
def capture_run(
|
|
cmd: list[str],
|
|
**kw: object,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
env_obj = kw.get("env")
|
|
if isinstance(env_obj, Mapping):
|
|
captured_env.update({str(k): str(v) for k, v in env_obj.items()})
|
|
return subprocess.CompletedProcess(list(cmd), 0, stdout="", stderr="")
|
|
|
|
monkeypatch.setattr(guard.subprocess, "run", capture_run)
|
|
|
|
guard._run_type_checker(
|
|
"basedpyright",
|
|
str(test_file),
|
|
guard.QualityConfig(),
|
|
original_file_path=str(test_file),
|
|
)
|
|
|
|
# PYTHONPATH should not be set (or not include src)
|
|
if "PYTHONPATH" in captured_env:
|
|
assert "src" not in captured_env["PYTHONPATH"]
|
|
finally:
|
|
import shutil
|
|
if root.exists():
|
|
shutil.rmtree(root)
|
|
|
|
|
|
class TestTypeCheckerIntegration:
|
|
"""Test type checker tool integration."""
|
|
|
|
def test_all_tools_disabled(self) -> None:
|
|
"""All type checkers disabled returns no issues."""
|
|
config = guard.QualityConfig(
|
|
basedpyright_enabled=False,
|
|
pyrefly_enabled=False,
|
|
sourcery_enabled=False,
|
|
)
|
|
|
|
issues = guard.run_type_checks("test.py", config)
|
|
assert issues == []
|
|
|
|
def test_tool_not_found_returns_warning(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Missing tool returns warning, doesn't crash."""
|
|
monkeypatch.setattr(guard.Path, "exists", lambda _: False, raising=False)
|
|
monkeypatch.setattr(guard, "_ensure_tool_installed", lambda _: False)
|
|
|
|
success, message = guard._run_type_checker(
|
|
"basedpyright",
|
|
"test.py",
|
|
guard.QualityConfig(),
|
|
)
|
|
|
|
assert success is True
|
|
assert "not available" in message
|
|
|
|
def test_tool_timeout_handled(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Tool timeout is handled gracefully."""
|
|
monkeypatch.setattr(guard.Path, "exists", lambda _: True, raising=False)
|
|
|
|
def timeout_run(*_args: object, **_kw: object) -> None:
|
|
raise subprocess.TimeoutExpired(cmd=["tool"], timeout=30)
|
|
|
|
monkeypatch.setattr(guard.subprocess, "run", timeout_run)
|
|
|
|
success, message = guard._run_type_checker(
|
|
"basedpyright",
|
|
"test.py",
|
|
guard.QualityConfig(),
|
|
)
|
|
|
|
assert success is True
|
|
assert "timeout" in message.lower()
|
|
|
|
def test_tool_os_error_handled(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""OS errors from tools are handled."""
|
|
monkeypatch.setattr(guard.Path, "exists", lambda _: True, raising=False)
|
|
|
|
def error_run(*_args: object, **_kw: object) -> None:
|
|
message = "Permission denied"
|
|
raise OSError(message)
|
|
|
|
monkeypatch.setattr(guard.subprocess, "run", error_run)
|
|
|
|
success, message = guard._run_type_checker(
|
|
"basedpyright",
|
|
"test.py",
|
|
guard.QualityConfig(),
|
|
)
|
|
|
|
assert success is True
|
|
assert "execution error" in message.lower()
|
|
|
|
def test_unknown_tool_returns_warning(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Unknown tool name returns warning."""
|
|
# Mock tool not existing
|
|
monkeypatch.setattr(guard.Path, "exists", lambda _: False, raising=False)
|
|
monkeypatch.setattr(guard, "_ensure_tool_installed", lambda _: False)
|
|
|
|
success, message = guard._run_type_checker(
|
|
"unknown_tool",
|
|
"test.py",
|
|
guard.QualityConfig(),
|
|
)
|
|
|
|
assert success is True
|
|
assert "not available" in message.lower()
|
|
|
|
|
|
class TestWorkingDirectoryScenarios:
|
|
"""Test different working directory scenarios."""
|
|
|
|
def test_cwd_set_to_project_root(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Type checker runs with cwd=project_root."""
|
|
root = Path.home() / f"test_cwd2_{os.getpid()}"
|
|
try:
|
|
(root / "src").mkdir(parents=True)
|
|
(root / ".venv/bin").mkdir(parents=True)
|
|
(root / "pyrightconfig.json").touch()
|
|
|
|
tool = root / ".venv/bin/basedpyright"
|
|
tool.write_text("#!/bin/bash\npwd")
|
|
tool.chmod(0o755)
|
|
|
|
test_file = root / "src/mod.py"
|
|
test_file.write_text("# test")
|
|
|
|
captured_cwd: list[Path] = []
|
|
|
|
def capture_run(
|
|
cmd: list[str],
|
|
**kw: object,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
cwd_obj = kw.get("cwd")
|
|
if cwd_obj is not None:
|
|
captured_cwd.append(Path(str(cwd_obj)))
|
|
return subprocess.CompletedProcess(list(cmd), 0, stdout="", stderr="")
|
|
|
|
monkeypatch.setattr(guard.subprocess, "run", capture_run)
|
|
|
|
guard._run_type_checker(
|
|
"basedpyright",
|
|
str(test_file),
|
|
guard.QualityConfig(),
|
|
original_file_path=str(test_file),
|
|
)
|
|
|
|
assert captured_cwd
|
|
assert captured_cwd[0] == root
|
|
finally:
|
|
import shutil
|
|
if root.exists():
|
|
shutil.rmtree(root)
|
|
|
|
|
|
class TestErrorConditions:
|
|
"""Test error handling scenarios."""
|
|
|
|
def test_invalid_syntax_in_content(self) -> None:
|
|
"""Invalid Python syntax is detected."""
|
|
issues = guard._detect_any_usage("def broken(:\n pass")
|
|
# Should still check for Any even with syntax error
|
|
assert isinstance(issues, list)
|
|
|
|
def test_tmp_dir_creation_permission_error(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Permission error creating .tmp is handled."""
|
|
def raise_permission(*_args: object, **_kw: object) -> None:
|
|
message = "Cannot create directory"
|
|
raise PermissionError(message)
|
|
|
|
monkeypatch.setattr(Path, "mkdir", raise_permission)
|
|
|
|
# Should raise and be caught by caller
|
|
with pytest.raises(PermissionError):
|
|
guard._get_project_tmp_dir("/some/file.py")
|
|
|
|
def test_empty_file_content(self) -> None:
|
|
"""Empty file content is handled."""
|
|
root = Path.home() / f"test_empty_{os.getpid()}"
|
|
try:
|
|
(root / ".venv/bin").mkdir(parents=True)
|
|
(root / "pyproject.toml").touch()
|
|
test_file = root / "empty.py"
|
|
test_file.write_text("")
|
|
|
|
# Should not crash
|
|
tmp_dir = guard._get_project_tmp_dir(str(test_file))
|
|
assert tmp_dir.exists()
|
|
finally:
|
|
import shutil
|
|
if root.exists():
|
|
shutil.rmtree(root)
|
|
|
|
|
|
class TestFileLocationVariations:
|
|
"""Test files in various locations."""
|
|
|
|
def test_file_in_tests_directory(self) -> None:
|
|
"""Test files are handled correctly."""
|
|
root = Path.home() / f"test_tests_{os.getpid()}"
|
|
try:
|
|
(root / "tests").mkdir(parents=True)
|
|
(root / ".git").mkdir()
|
|
|
|
test_file = root / "tests/test_module.py"
|
|
test_file.write_text("# test")
|
|
|
|
found_root = guard._find_project_root(str(test_file))
|
|
assert found_root == root
|
|
|
|
# Test file detection
|
|
assert guard.is_test_file(str(test_file))
|
|
finally:
|
|
import shutil
|
|
if root.exists():
|
|
shutil.rmtree(root)
|
|
|
|
def test_file_in_project_root(self) -> None:
|
|
"""File directly in project root."""
|
|
root = Path.home() / f"test_rootfile_{os.getpid()}"
|
|
try:
|
|
root.mkdir()
|
|
(root / ".git").mkdir()
|
|
|
|
test_file = root / "main.py"
|
|
test_file.write_text("# test")
|
|
|
|
found_root = guard._find_project_root(str(test_file))
|
|
assert found_root == root
|
|
finally:
|
|
import shutil
|
|
if root.exists():
|
|
shutil.rmtree(root)
|
|
|
|
|
|
class TestTempFileManagement:
|
|
"""Test temporary file handling."""
|
|
|
|
def test_temp_files_cleaned_up(self) -> None:
|
|
"""Temp files are deleted after analysis."""
|
|
root = Path.home() / f"test_cleanup_{os.getpid()}"
|
|
try:
|
|
(root / "src").mkdir(parents=True)
|
|
(root / ".venv/bin").mkdir(parents=True)
|
|
(root / "pyproject.toml").touch()
|
|
|
|
test_file = root / "src/mod.py"
|
|
test_file.write_text("def foo(): pass")
|
|
|
|
tmp_dir = root / ".tmp"
|
|
|
|
# Analyze code (should create and delete temp file)
|
|
config = guard.QualityConfig(
|
|
duplicate_enabled=False,
|
|
complexity_enabled=False,
|
|
modernization_enabled=False,
|
|
basedpyright_enabled=False,
|
|
pyrefly_enabled=False,
|
|
sourcery_enabled=False,
|
|
)
|
|
|
|
guard.analyze_code_quality(
|
|
"def foo(): pass",
|
|
str(test_file),
|
|
config,
|
|
enable_type_checks=False,
|
|
)
|
|
|
|
# .tmp directory should exist but temp file should be gone
|
|
if tmp_dir.exists():
|
|
temp_files = list(tmp_dir.glob("hook_validation_*"))
|
|
assert not temp_files
|
|
finally:
|
|
import shutil
|
|
if root.exists():
|
|
shutil.rmtree(root)
|
|
|
|
def test_temp_file_in_correct_location(self) -> None:
|
|
"""Temp files created in project .tmp/ not /tmp."""
|
|
root = Path.home() / f"test_tmploc_{os.getpid()}"
|
|
try:
|
|
(root / "src").mkdir(parents=True)
|
|
(root / "pyproject.toml").touch()
|
|
|
|
test_file = root / "src/mod.py"
|
|
test_file.write_text("# test")
|
|
|
|
tmp_dir = guard._get_project_tmp_dir(str(test_file))
|
|
|
|
# Should be in project, not /tmp
|
|
assert str(tmp_dir).startswith(str(root))
|
|
assert not str(tmp_dir).startswith(gettempdir())
|
|
finally:
|
|
import shutil
|
|
if root.exists():
|
|
shutil.rmtree(root)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|