| """Tests for progressive subdirectory hint discovery.""" |
|
|
| import os |
| import pytest |
| from pathlib import Path |
| from unittest.mock import patch |
|
|
| from agent.subdirectory_hints import SubdirectoryHintTracker |
|
|
|
|
| @pytest.fixture |
| def project(tmp_path): |
| """Create a mock project tree with hint files in subdirectories.""" |
| |
| (tmp_path / "AGENTS.md").write_text("Root project instructions") |
|
|
| |
| backend = tmp_path / "backend" |
| backend.mkdir() |
| (backend / "AGENTS.md").write_text("Backend-specific instructions:\n- Use FastAPI\n- Always add type hints") |
|
|
| |
| (backend / "src").mkdir() |
| (backend / "src" / "main.py").write_text("print('hello')") |
|
|
| |
| frontend = tmp_path / "frontend" |
| frontend.mkdir() |
| (frontend / "CLAUDE.md").write_text("Frontend rules:\n- Use TypeScript\n- No any types") |
|
|
| |
| (tmp_path / "docs").mkdir() |
| (tmp_path / "docs" / "README.md").write_text("Documentation") |
|
|
| |
| deep = tmp_path / "deep" / "nested" / "path" |
| deep.mkdir(parents=True) |
| (deep / ".cursorrules").write_text("Cursor rules for nested path") |
|
|
| return tmp_path |
|
|
|
|
| class TestSubdirectoryHintTracker: |
| """Unit tests for SubdirectoryHintTracker.""" |
|
|
| def test_working_dir_not_loaded(self, project): |
| """Working dir is pre-marked as loaded (startup handles it).""" |
| tracker = SubdirectoryHintTracker(working_dir=str(project)) |
| |
| result = tracker.check_tool_call("read_file", {"path": str(project / "AGENTS.md")}) |
| assert result is None |
|
|
| def test_discovers_agents_md_via_ancestor_walk(self, project): |
| """Reading backend/src/main.py discovers backend/AGENTS.md via ancestor walk.""" |
| tracker = SubdirectoryHintTracker(working_dir=str(project)) |
| result = tracker.check_tool_call( |
| "read_file", {"path": str(project / "backend" / "src" / "main.py")} |
| ) |
| |
| assert result is not None |
| assert "Backend-specific instructions" in result |
| |
| result2 = tracker.check_tool_call( |
| "read_file", {"path": str(project / "backend" / "AGENTS.md")} |
| ) |
| assert result2 is None |
|
|
| def test_discovers_claude_md(self, project): |
| """Frontend CLAUDE.md should be discovered.""" |
| tracker = SubdirectoryHintTracker(working_dir=str(project)) |
| result = tracker.check_tool_call( |
| "read_file", {"path": str(project / "frontend" / "index.ts")} |
| ) |
| assert result is not None |
| assert "Frontend rules" in result |
|
|
| def test_no_duplicate_loading(self, project): |
| """Same directory should not be loaded twice.""" |
| tracker = SubdirectoryHintTracker(working_dir=str(project)) |
| result1 = tracker.check_tool_call( |
| "read_file", {"path": str(project / "frontend" / "a.ts")} |
| ) |
| assert result1 is not None |
|
|
| result2 = tracker.check_tool_call( |
| "read_file", {"path": str(project / "frontend" / "b.ts")} |
| ) |
| assert result2 is None |
|
|
| def test_no_hints_in_empty_directory(self, project): |
| """Directories without hint files return None.""" |
| tracker = SubdirectoryHintTracker(working_dir=str(project)) |
| result = tracker.check_tool_call( |
| "read_file", {"path": str(project / "docs" / "README.md")} |
| ) |
| assert result is None |
|
|
| def test_terminal_command_path_extraction(self, project): |
| """Paths extracted from terminal commands.""" |
| tracker = SubdirectoryHintTracker(working_dir=str(project)) |
| result = tracker.check_tool_call( |
| "terminal", {"command": f"cat {project / 'frontend' / 'index.ts'}"} |
| ) |
| assert result is not None |
| assert "Frontend rules" in result |
|
|
| def test_terminal_cd_command(self, project): |
| """cd into a directory with hints.""" |
| tracker = SubdirectoryHintTracker(working_dir=str(project)) |
| result = tracker.check_tool_call( |
| "terminal", {"command": f"cd {project / 'backend'} && ls"} |
| ) |
| assert result is not None |
| assert "Backend-specific instructions" in result |
|
|
| def test_relative_path(self, project): |
| """Relative paths resolved against working_dir.""" |
| tracker = SubdirectoryHintTracker(working_dir=str(project)) |
| result = tracker.check_tool_call( |
| "read_file", {"path": "frontend/index.ts"} |
| ) |
| assert result is not None |
| assert "Frontend rules" in result |
|
|
| def test_outside_working_dir_still_checked(self, tmp_path, project): |
| """Paths outside working_dir are still checked for hints.""" |
| other_project = tmp_path / "other" |
| other_project.mkdir() |
| (other_project / "AGENTS.md").write_text("Other project rules") |
| tracker = SubdirectoryHintTracker(working_dir=str(project)) |
| result = tracker.check_tool_call( |
| "read_file", {"path": str(other_project / "file.py")} |
| ) |
| assert result is not None |
| assert "Other project rules" in result |
|
|
| def test_workdir_arg(self, project): |
| """The workdir argument from terminal tool is checked.""" |
| tracker = SubdirectoryHintTracker(working_dir=str(project)) |
| result = tracker.check_tool_call( |
| "terminal", {"command": "ls", "workdir": str(project / "frontend")} |
| ) |
| assert result is not None |
| assert "Frontend rules" in result |
|
|
| def test_deeply_nested_cursorrules(self, project): |
| """Deeply nested .cursorrules should be discovered.""" |
| tracker = SubdirectoryHintTracker(working_dir=str(project)) |
| result = tracker.check_tool_call( |
| "read_file", {"path": str(project / "deep" / "nested" / "path" / "file.py")} |
| ) |
| assert result is not None |
| assert "Cursor rules for nested path" in result |
|
|
| def test_hint_format_includes_path(self, project): |
| """Discovered hints should indicate which file they came from.""" |
| tracker = SubdirectoryHintTracker(working_dir=str(project)) |
| result = tracker.check_tool_call( |
| "read_file", {"path": str(project / "backend" / "file.py")} |
| ) |
| assert result is not None |
| assert "Subdirectory context discovered:" in result |
| assert "AGENTS.md" in result |
|
|
| def test_truncation_of_large_hints(self, tmp_path): |
| """Hint files over the limit are truncated.""" |
| sub = tmp_path / "bigdir" |
| sub.mkdir() |
| (sub / "AGENTS.md").write_text("x" * 20_000) |
|
|
| tracker = SubdirectoryHintTracker(working_dir=str(tmp_path)) |
| result = tracker.check_tool_call( |
| "read_file", {"path": str(sub / "file.py")} |
| ) |
| assert result is not None |
| assert "truncated" in result.lower() |
| |
| assert len(result) < 20_000 |
|
|
| def test_empty_args(self, project): |
| """Empty args should not crash.""" |
| tracker = SubdirectoryHintTracker(working_dir=str(project)) |
| assert tracker.check_tool_call("read_file", {}) is None |
| assert tracker.check_tool_call("terminal", {"command": ""}) is None |
|
|
| def test_url_in_command_ignored(self, project): |
| """URLs in shell commands should not be treated as paths.""" |
| tracker = SubdirectoryHintTracker(working_dir=str(project)) |
| result = tracker.check_tool_call( |
| "terminal", {"command": "curl https://example.com/frontend/api"} |
| ) |
| assert result is None |
|
|
|
|
| class TestPermissionErrorHandling: |
| """Regression tests for PermissionError in filesystem checks (ref #6214).""" |
|
|
| def test_is_valid_subdir_permission_error(self, tmp_path): |
| """_is_valid_subdir should return False when is_dir() raises PermissionError.""" |
| tracker = SubdirectoryHintTracker(working_dir=str(tmp_path)) |
| restricted = tmp_path / "restricted" |
| restricted.mkdir() |
| with patch.object(Path, "is_dir", side_effect=PermissionError("Permission denied")): |
| assert tracker._is_valid_subdir(restricted) is False |
|
|
| def test_load_hints_permission_error_on_is_file(self, tmp_path): |
| """_load_hints_for_directory should skip files when is_file() raises PermissionError.""" |
| tracker = SubdirectoryHintTracker(working_dir=str(tmp_path)) |
| restricted = tmp_path / "restricted" |
| restricted.mkdir() |
| original_is_file = Path.is_file |
| def patched_is_file(self): |
| if "restricted" in str(self): |
| raise PermissionError("Permission denied") |
| return original_is_file(self) |
| with patch.object(Path, "is_file", patched_is_file): |
| result = tracker._load_hints_for_directory(restricted) |
| assert result is None |
|
|
| def test_check_tool_call_survives_inaccessible_path(self, project): |
| """Full check_tool_call should not crash when a path is inaccessible.""" |
| tracker = SubdirectoryHintTracker(working_dir=str(project)) |
| original_is_dir = Path.is_dir |
| def patched_is_dir(self): |
| if "backend" in str(self) and "src" not in str(self): |
| raise PermissionError("Permission denied") |
| return original_is_dir(self) |
| with patch.object(Path, "is_dir", patched_is_dir): |
| |
| result = tracker.check_tool_call( |
| "read_file", {"path": str(project / "backend" / "src" / "main.py")} |
| ) |
| |
| assert result is None or isinstance(result, str) |
|
|