mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +08:00
feat(cli): 添加 --rule 选项支持模板自动发现
重构 ccw cli 模板系统: - 新增 template-discovery.ts 模块,支持扁平化模板自动发现 - 添加 --rule <template> 选项,自动加载 protocol 和 template - 模板目录从嵌套结构 (prompts/category/file.txt) 迁移到扁平结构 (prompts/category-function.txt) - 更新所有 agent/command 文件,使用 $PROTO $TMPL 环境变量替代 $(cat ...) 模式 - 支持模糊匹配:--rule 02-review-architecture 可匹配 analysis-review-architecture.txt 其他更新: - Dashboard: 添加 Claude Manager 和 Issue Manager 页面 - Codex-lens: 增强 chain_search 和 clustering 模块 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1
codex-lens/tests/mcp/__init__.py
Normal file
1
codex-lens/tests/mcp/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for MCP (Model Context Protocol) module."""
|
||||
208
codex-lens/tests/mcp/test_hooks.py
Normal file
208
codex-lens/tests/mcp/test_hooks.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""Tests for MCP hooks module."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
from pathlib import Path
|
||||
|
||||
from codexlens.mcp.hooks import HookManager, create_context_for_prompt
|
||||
from codexlens.mcp.schema import MCPContext, SymbolInfo
|
||||
|
||||
|
||||
class TestHookManager:
|
||||
"""Test HookManager class."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_provider(self):
|
||||
"""Create a mock MCP provider."""
|
||||
provider = Mock()
|
||||
provider.build_context.return_value = MCPContext(
|
||||
symbol=SymbolInfo("test_func", "function", "/test.py", 1, 10),
|
||||
context_type="symbol_explanation",
|
||||
)
|
||||
provider.build_context_for_file.return_value = MCPContext(
|
||||
context_type="file_overview",
|
||||
)
|
||||
return provider
|
||||
|
||||
@pytest.fixture
|
||||
def hook_manager(self, mock_provider):
|
||||
"""Create a HookManager with mocked provider."""
|
||||
return HookManager(mock_provider)
|
||||
|
||||
def test_default_hooks_registered(self, hook_manager):
|
||||
"""Default hooks are registered on initialization."""
|
||||
assert "explain" in hook_manager._pre_hooks
|
||||
assert "refactor" in hook_manager._pre_hooks
|
||||
assert "document" in hook_manager._pre_hooks
|
||||
|
||||
def test_execute_pre_hook_returns_context(self, hook_manager, mock_provider):
|
||||
"""execute_pre_hook returns MCPContext for registered hook."""
|
||||
result = hook_manager.execute_pre_hook("explain", {"symbol": "my_func"})
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, MCPContext)
|
||||
mock_provider.build_context.assert_called_once()
|
||||
|
||||
def test_execute_pre_hook_returns_none_for_unknown_action(self, hook_manager):
|
||||
"""execute_pre_hook returns None for unregistered action."""
|
||||
result = hook_manager.execute_pre_hook("unknown_action", {"symbol": "test"})
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_execute_pre_hook_handles_exception(self, hook_manager, mock_provider):
|
||||
"""execute_pre_hook handles provider exceptions gracefully."""
|
||||
mock_provider.build_context.side_effect = Exception("Provider failed")
|
||||
|
||||
result = hook_manager.execute_pre_hook("explain", {"symbol": "my_func"})
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_execute_post_hook_no_error_for_unregistered(self, hook_manager):
|
||||
"""execute_post_hook doesn't error for unregistered action."""
|
||||
# Should not raise
|
||||
hook_manager.execute_post_hook("unknown", {"result": "data"})
|
||||
|
||||
def test_pre_explain_hook_calls_build_context(self, hook_manager, mock_provider):
|
||||
"""_pre_explain_hook calls build_context correctly."""
|
||||
hook_manager.execute_pre_hook("explain", {"symbol": "my_func"})
|
||||
|
||||
mock_provider.build_context.assert_called_with(
|
||||
symbol_name="my_func",
|
||||
context_type="symbol_explanation",
|
||||
include_references=True,
|
||||
include_related=True,
|
||||
)
|
||||
|
||||
def test_pre_explain_hook_returns_none_without_symbol(self, hook_manager, mock_provider):
|
||||
"""_pre_explain_hook returns None when symbol param missing."""
|
||||
result = hook_manager.execute_pre_hook("explain", {})
|
||||
|
||||
assert result is None
|
||||
mock_provider.build_context.assert_not_called()
|
||||
|
||||
def test_pre_refactor_hook_calls_build_context(self, hook_manager, mock_provider):
|
||||
"""_pre_refactor_hook calls build_context with refactor settings."""
|
||||
hook_manager.execute_pre_hook("refactor", {"symbol": "my_class"})
|
||||
|
||||
mock_provider.build_context.assert_called_with(
|
||||
symbol_name="my_class",
|
||||
context_type="refactor_context",
|
||||
include_references=True,
|
||||
include_related=True,
|
||||
max_references=20,
|
||||
)
|
||||
|
||||
def test_pre_refactor_hook_returns_none_without_symbol(self, hook_manager, mock_provider):
|
||||
"""_pre_refactor_hook returns None when symbol param missing."""
|
||||
result = hook_manager.execute_pre_hook("refactor", {})
|
||||
|
||||
assert result is None
|
||||
mock_provider.build_context.assert_not_called()
|
||||
|
||||
def test_pre_document_hook_with_symbol(self, hook_manager, mock_provider):
|
||||
"""_pre_document_hook uses build_context when symbol provided."""
|
||||
hook_manager.execute_pre_hook("document", {"symbol": "my_func"})
|
||||
|
||||
mock_provider.build_context.assert_called_with(
|
||||
symbol_name="my_func",
|
||||
context_type="documentation_context",
|
||||
include_references=False,
|
||||
include_related=True,
|
||||
)
|
||||
|
||||
def test_pre_document_hook_with_file_path(self, hook_manager, mock_provider):
|
||||
"""_pre_document_hook uses build_context_for_file when file_path provided."""
|
||||
hook_manager.execute_pre_hook("document", {"file_path": "/src/module.py"})
|
||||
|
||||
mock_provider.build_context_for_file.assert_called_once()
|
||||
call_args = mock_provider.build_context_for_file.call_args
|
||||
assert call_args[0][0] == Path("/src/module.py")
|
||||
assert call_args[1].get("context_type") == "file_documentation"
|
||||
|
||||
def test_pre_document_hook_prefers_symbol_over_file(self, hook_manager, mock_provider):
|
||||
"""_pre_document_hook prefers symbol when both provided."""
|
||||
hook_manager.execute_pre_hook(
|
||||
"document", {"symbol": "my_func", "file_path": "/src/module.py"}
|
||||
)
|
||||
|
||||
mock_provider.build_context.assert_called_once()
|
||||
mock_provider.build_context_for_file.assert_not_called()
|
||||
|
||||
def test_pre_document_hook_returns_none_without_params(self, hook_manager, mock_provider):
|
||||
"""_pre_document_hook returns None when neither symbol nor file_path provided."""
|
||||
result = hook_manager.execute_pre_hook("document", {})
|
||||
|
||||
assert result is None
|
||||
mock_provider.build_context.assert_not_called()
|
||||
mock_provider.build_context_for_file.assert_not_called()
|
||||
|
||||
def test_register_pre_hook(self, hook_manager):
|
||||
"""register_pre_hook adds custom hook."""
|
||||
custom_hook = Mock(return_value=MCPContext())
|
||||
|
||||
hook_manager.register_pre_hook("custom_action", custom_hook)
|
||||
|
||||
assert "custom_action" in hook_manager._pre_hooks
|
||||
hook_manager.execute_pre_hook("custom_action", {"data": "value"})
|
||||
custom_hook.assert_called_once_with({"data": "value"})
|
||||
|
||||
def test_register_post_hook(self, hook_manager):
|
||||
"""register_post_hook adds custom hook."""
|
||||
custom_hook = Mock()
|
||||
|
||||
hook_manager.register_post_hook("custom_action", custom_hook)
|
||||
|
||||
assert "custom_action" in hook_manager._post_hooks
|
||||
hook_manager.execute_post_hook("custom_action", {"result": "data"})
|
||||
custom_hook.assert_called_once_with({"result": "data"})
|
||||
|
||||
def test_execute_post_hook_handles_exception(self, hook_manager):
|
||||
"""execute_post_hook handles hook exceptions gracefully."""
|
||||
failing_hook = Mock(side_effect=Exception("Hook failed"))
|
||||
hook_manager.register_post_hook("failing", failing_hook)
|
||||
|
||||
# Should not raise
|
||||
hook_manager.execute_post_hook("failing", {"data": "value"})
|
||||
|
||||
|
||||
class TestCreateContextForPrompt:
|
||||
"""Test create_context_for_prompt function."""
|
||||
|
||||
def test_returns_prompt_injection_string(self):
|
||||
"""create_context_for_prompt returns formatted string."""
|
||||
mock_provider = Mock()
|
||||
mock_provider.build_context.return_value = MCPContext(
|
||||
symbol=SymbolInfo("test_func", "function", "/test.py", 1, 10),
|
||||
definition="def test_func(): pass",
|
||||
)
|
||||
|
||||
result = create_context_for_prompt(
|
||||
mock_provider, "explain", {"symbol": "test_func"}
|
||||
)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "<code_context>" in result
|
||||
assert "test_func" in result
|
||||
assert "</code_context>" in result
|
||||
|
||||
def test_returns_empty_string_when_no_context(self):
|
||||
"""create_context_for_prompt returns empty string when no context built."""
|
||||
mock_provider = Mock()
|
||||
mock_provider.build_context.return_value = None
|
||||
|
||||
result = create_context_for_prompt(
|
||||
mock_provider, "explain", {"symbol": "nonexistent"}
|
||||
)
|
||||
|
||||
assert result == ""
|
||||
|
||||
def test_returns_empty_string_for_unknown_action(self):
|
||||
"""create_context_for_prompt returns empty string for unregistered action."""
|
||||
mock_provider = Mock()
|
||||
|
||||
result = create_context_for_prompt(
|
||||
mock_provider, "unknown_action", {"data": "value"}
|
||||
)
|
||||
|
||||
assert result == ""
|
||||
mock_provider.build_context.assert_not_called()
|
||||
383
codex-lens/tests/mcp/test_provider.py
Normal file
383
codex-lens/tests/mcp/test_provider.py
Normal file
@@ -0,0 +1,383 @@
|
||||
"""Tests for MCP provider."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
from codexlens.mcp.provider import MCPProvider
|
||||
from codexlens.mcp.schema import MCPContext, SymbolInfo, ReferenceInfo
|
||||
|
||||
|
||||
class TestMCPProvider:
|
||||
"""Test MCPProvider class."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_global_index(self):
|
||||
"""Create a mock global index."""
|
||||
return Mock()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_search_engine(self):
|
||||
"""Create a mock search engine."""
|
||||
return Mock()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_registry(self):
|
||||
"""Create a mock registry."""
|
||||
return Mock()
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self, mock_global_index, mock_search_engine, mock_registry):
|
||||
"""Create an MCPProvider with mocked dependencies."""
|
||||
return MCPProvider(mock_global_index, mock_search_engine, mock_registry)
|
||||
|
||||
def test_build_context_returns_none_for_unknown_symbol(self, provider, mock_global_index):
|
||||
"""build_context returns None when symbol is not found."""
|
||||
mock_global_index.search.return_value = []
|
||||
|
||||
result = provider.build_context("unknown_symbol")
|
||||
|
||||
assert result is None
|
||||
mock_global_index.search.assert_called_once_with(
|
||||
"unknown_symbol", prefix_mode=False, limit=1
|
||||
)
|
||||
|
||||
def test_build_context_returns_mcp_context(
|
||||
self, provider, mock_global_index, mock_search_engine
|
||||
):
|
||||
"""build_context returns MCPContext for known symbol."""
|
||||
mock_symbol = Mock()
|
||||
mock_symbol.name = "my_func"
|
||||
mock_symbol.kind = "function"
|
||||
mock_symbol.file = "/test.py"
|
||||
mock_symbol.range = (10, 20)
|
||||
|
||||
mock_global_index.search.return_value = [mock_symbol]
|
||||
mock_search_engine.search_references.return_value = []
|
||||
|
||||
result = provider.build_context("my_func")
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, MCPContext)
|
||||
assert result.symbol is not None
|
||||
assert result.symbol.name == "my_func"
|
||||
assert result.symbol.kind == "function"
|
||||
assert result.context_type == "symbol_explanation"
|
||||
|
||||
def test_build_context_with_custom_context_type(
|
||||
self, provider, mock_global_index, mock_search_engine
|
||||
):
|
||||
"""build_context respects custom context_type."""
|
||||
mock_symbol = Mock()
|
||||
mock_symbol.name = "my_func"
|
||||
mock_symbol.kind = "function"
|
||||
mock_symbol.file = "/test.py"
|
||||
mock_symbol.range = (10, 20)
|
||||
|
||||
mock_global_index.search.return_value = [mock_symbol]
|
||||
mock_search_engine.search_references.return_value = []
|
||||
|
||||
result = provider.build_context("my_func", context_type="refactor_context")
|
||||
|
||||
assert result is not None
|
||||
assert result.context_type == "refactor_context"
|
||||
|
||||
def test_build_context_includes_references(
|
||||
self, provider, mock_global_index, mock_search_engine
|
||||
):
|
||||
"""build_context includes references when include_references=True."""
|
||||
mock_symbol = Mock()
|
||||
mock_symbol.name = "my_func"
|
||||
mock_symbol.kind = "function"
|
||||
mock_symbol.file = "/test.py"
|
||||
mock_symbol.range = (10, 20)
|
||||
|
||||
mock_ref = Mock()
|
||||
mock_ref.file_path = "/caller.py"
|
||||
mock_ref.line = 25
|
||||
mock_ref.column = 4
|
||||
mock_ref.context = "result = my_func()"
|
||||
mock_ref.relationship_type = "call"
|
||||
|
||||
mock_global_index.search.return_value = [mock_symbol]
|
||||
mock_search_engine.search_references.return_value = [mock_ref]
|
||||
|
||||
result = provider.build_context("my_func", include_references=True)
|
||||
|
||||
assert result is not None
|
||||
assert len(result.references) == 1
|
||||
assert result.references[0].file_path == "/caller.py"
|
||||
assert result.references[0].line == 25
|
||||
assert result.references[0].relationship_type == "call"
|
||||
|
||||
def test_build_context_excludes_references_when_disabled(
|
||||
self, provider, mock_global_index, mock_search_engine
|
||||
):
|
||||
"""build_context excludes references when include_references=False."""
|
||||
mock_symbol = Mock()
|
||||
mock_symbol.name = "my_func"
|
||||
mock_symbol.kind = "function"
|
||||
mock_symbol.file = "/test.py"
|
||||
mock_symbol.range = (10, 20)
|
||||
|
||||
mock_global_index.search.return_value = [mock_symbol]
|
||||
mock_search_engine.search_references.return_value = []
|
||||
|
||||
# Disable both references and related to avoid any search_references calls
|
||||
result = provider.build_context(
|
||||
"my_func", include_references=False, include_related=False
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert len(result.references) == 0
|
||||
mock_search_engine.search_references.assert_not_called()
|
||||
|
||||
def test_build_context_respects_max_references(
|
||||
self, provider, mock_global_index, mock_search_engine
|
||||
):
|
||||
"""build_context passes max_references to search engine."""
|
||||
mock_symbol = Mock()
|
||||
mock_symbol.name = "my_func"
|
||||
mock_symbol.kind = "function"
|
||||
mock_symbol.file = "/test.py"
|
||||
mock_symbol.range = (10, 20)
|
||||
|
||||
mock_global_index.search.return_value = [mock_symbol]
|
||||
mock_search_engine.search_references.return_value = []
|
||||
|
||||
# Disable include_related to test only the references call
|
||||
provider.build_context("my_func", max_references=5, include_related=False)
|
||||
|
||||
mock_search_engine.search_references.assert_called_once_with(
|
||||
"my_func", limit=5
|
||||
)
|
||||
|
||||
def test_build_context_includes_metadata(
|
||||
self, provider, mock_global_index, mock_search_engine
|
||||
):
|
||||
"""build_context includes source metadata."""
|
||||
mock_symbol = Mock()
|
||||
mock_symbol.name = "my_func"
|
||||
mock_symbol.kind = "function"
|
||||
mock_symbol.file = "/test.py"
|
||||
mock_symbol.range = (10, 20)
|
||||
|
||||
mock_global_index.search.return_value = [mock_symbol]
|
||||
mock_search_engine.search_references.return_value = []
|
||||
|
||||
result = provider.build_context("my_func")
|
||||
|
||||
assert result is not None
|
||||
assert result.metadata.get("source") == "codex-lens"
|
||||
|
||||
def test_extract_definition_with_valid_file(self, provider):
|
||||
"""_extract_definition reads file content correctly."""
|
||||
# Create a temporary file with some content
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
|
||||
f.write("# Line 1\n")
|
||||
f.write("# Line 2\n")
|
||||
f.write("def my_func():\n") # Line 3
|
||||
f.write(" pass\n") # Line 4
|
||||
f.write("# Line 5\n")
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
mock_symbol = Mock()
|
||||
mock_symbol.file = temp_path
|
||||
mock_symbol.range = (3, 4) # 1-based line numbers
|
||||
|
||||
definition = provider._extract_definition(mock_symbol)
|
||||
|
||||
assert definition is not None
|
||||
assert "def my_func():" in definition
|
||||
assert "pass" in definition
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
|
||||
def test_extract_definition_returns_none_for_missing_file(self, provider):
|
||||
"""_extract_definition returns None for non-existent file."""
|
||||
mock_symbol = Mock()
|
||||
mock_symbol.file = "/nonexistent/path/file.py"
|
||||
mock_symbol.range = (1, 5)
|
||||
|
||||
definition = provider._extract_definition(mock_symbol)
|
||||
|
||||
assert definition is None
|
||||
|
||||
def test_extract_definition_returns_none_for_none_file(self, provider):
|
||||
"""_extract_definition returns None when symbol.file is None."""
|
||||
mock_symbol = Mock()
|
||||
mock_symbol.file = None
|
||||
mock_symbol.range = (1, 5)
|
||||
|
||||
definition = provider._extract_definition(mock_symbol)
|
||||
|
||||
assert definition is None
|
||||
|
||||
def test_build_context_for_file_returns_context(
|
||||
self, provider, mock_global_index
|
||||
):
|
||||
"""build_context_for_file returns MCPContext."""
|
||||
mock_global_index.search.return_value = []
|
||||
|
||||
result = provider.build_context_for_file(
|
||||
Path("/test/file.py"),
|
||||
context_type="file_overview",
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert isinstance(result, MCPContext)
|
||||
assert result.context_type == "file_overview"
|
||||
assert result.metadata.get("file_path") == str(Path("/test/file.py"))
|
||||
|
||||
def test_build_context_for_file_includes_symbols(
|
||||
self, provider, mock_global_index
|
||||
):
|
||||
"""build_context_for_file includes symbols from the file."""
|
||||
# Create temp file to get resolved path
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
|
||||
f.write("def func(): pass\n")
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
mock_symbol = Mock()
|
||||
mock_symbol.name = "func"
|
||||
mock_symbol.kind = "function"
|
||||
mock_symbol.file = temp_path
|
||||
mock_symbol.range = (1, 1)
|
||||
|
||||
mock_global_index.search.return_value = [mock_symbol]
|
||||
|
||||
result = provider.build_context_for_file(Path(temp_path))
|
||||
|
||||
assert result is not None
|
||||
# Symbols from this file should be in related_symbols
|
||||
assert len(result.related_symbols) >= 0 # May be 0 if filtering doesn't match
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
|
||||
|
||||
class TestMCPProviderRelatedSymbols:
|
||||
"""Test related symbols functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
"""Create provider with mocks."""
|
||||
mock_global_index = Mock()
|
||||
mock_search_engine = Mock()
|
||||
mock_registry = Mock()
|
||||
return MCPProvider(mock_global_index, mock_search_engine, mock_registry)
|
||||
|
||||
def test_get_related_symbols_from_references(self, provider):
|
||||
"""_get_related_symbols extracts symbols from references."""
|
||||
mock_symbol = Mock()
|
||||
mock_symbol.name = "my_func"
|
||||
mock_symbol.file = "/test.py"
|
||||
|
||||
mock_ref1 = Mock()
|
||||
mock_ref1.file_path = "/caller1.py"
|
||||
mock_ref1.relationship_type = "call"
|
||||
|
||||
mock_ref2 = Mock()
|
||||
mock_ref2.file_path = "/caller2.py"
|
||||
mock_ref2.relationship_type = "import"
|
||||
|
||||
provider.search_engine.search_references.return_value = [mock_ref1, mock_ref2]
|
||||
|
||||
related = provider._get_related_symbols(mock_symbol)
|
||||
|
||||
assert len(related) == 2
|
||||
assert related[0].relationship == "call"
|
||||
assert related[1].relationship == "import"
|
||||
|
||||
def test_get_related_symbols_limits_results(self, provider):
|
||||
"""_get_related_symbols limits to 10 unique relationship types."""
|
||||
mock_symbol = Mock()
|
||||
mock_symbol.name = "my_func"
|
||||
mock_symbol.file = "/test.py"
|
||||
|
||||
# Create 15 references with unique relationship types
|
||||
refs = []
|
||||
for i in range(15):
|
||||
ref = Mock()
|
||||
ref.file_path = f"/file{i}.py"
|
||||
ref.relationship_type = f"type{i}"
|
||||
refs.append(ref)
|
||||
|
||||
provider.search_engine.search_references.return_value = refs
|
||||
|
||||
related = provider._get_related_symbols(mock_symbol)
|
||||
|
||||
assert len(related) <= 10
|
||||
|
||||
def test_get_related_symbols_handles_exception(self, provider):
|
||||
"""_get_related_symbols handles exceptions gracefully."""
|
||||
mock_symbol = Mock()
|
||||
mock_symbol.name = "my_func"
|
||||
mock_symbol.file = "/test.py"
|
||||
|
||||
provider.search_engine.search_references.side_effect = Exception("Search failed")
|
||||
|
||||
related = provider._get_related_symbols(mock_symbol)
|
||||
|
||||
assert related == []
|
||||
|
||||
|
||||
class TestMCPProviderIntegration:
|
||||
"""Integration-style tests for MCPProvider."""
|
||||
|
||||
def test_full_context_workflow(self):
|
||||
"""Test complete context building workflow."""
|
||||
# Create temp file
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
|
||||
f.write("def my_function(arg1, arg2):\n")
|
||||
f.write(" '''This is my function.'''\n")
|
||||
f.write(" return arg1 + arg2\n")
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
# Setup mocks
|
||||
mock_global_index = Mock()
|
||||
mock_search_engine = Mock()
|
||||
mock_registry = Mock()
|
||||
|
||||
mock_symbol = Mock()
|
||||
mock_symbol.name = "my_function"
|
||||
mock_symbol.kind = "function"
|
||||
mock_symbol.file = temp_path
|
||||
mock_symbol.range = (1, 3)
|
||||
|
||||
mock_ref = Mock()
|
||||
mock_ref.file_path = "/user.py"
|
||||
mock_ref.line = 10
|
||||
mock_ref.column = 4
|
||||
mock_ref.context = "result = my_function(1, 2)"
|
||||
mock_ref.relationship_type = "call"
|
||||
|
||||
mock_global_index.search.return_value = [mock_symbol]
|
||||
mock_search_engine.search_references.return_value = [mock_ref]
|
||||
|
||||
provider = MCPProvider(mock_global_index, mock_search_engine, mock_registry)
|
||||
context = provider.build_context("my_function")
|
||||
|
||||
assert context is not None
|
||||
assert context.symbol.name == "my_function"
|
||||
assert context.definition is not None
|
||||
assert "def my_function" in context.definition
|
||||
assert len(context.references) == 1
|
||||
assert context.references[0].relationship_type == "call"
|
||||
|
||||
# Test serialization
|
||||
json_str = context.to_json()
|
||||
assert "my_function" in json_str
|
||||
|
||||
# Test prompt injection
|
||||
prompt = context.to_prompt_injection()
|
||||
assert "<code_context>" in prompt
|
||||
assert "my_function" in prompt
|
||||
assert "</code_context>" in prompt
|
||||
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
288
codex-lens/tests/mcp/test_schema.py
Normal file
288
codex-lens/tests/mcp/test_schema.py
Normal file
@@ -0,0 +1,288 @@
|
||||
"""Tests for MCP schema."""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
|
||||
from codexlens.mcp.schema import (
|
||||
MCPContext,
|
||||
SymbolInfo,
|
||||
ReferenceInfo,
|
||||
RelatedSymbol,
|
||||
)
|
||||
|
||||
|
||||
class TestSymbolInfo:
|
||||
"""Test SymbolInfo dataclass."""
|
||||
|
||||
def test_to_dict_includes_all_fields(self):
|
||||
"""SymbolInfo.to_dict() includes all non-None fields."""
|
||||
info = SymbolInfo(
|
||||
name="func",
|
||||
kind="function",
|
||||
file_path="/test.py",
|
||||
line_start=10,
|
||||
line_end=20,
|
||||
signature="def func():",
|
||||
documentation="Test doc",
|
||||
)
|
||||
d = info.to_dict()
|
||||
assert d["name"] == "func"
|
||||
assert d["kind"] == "function"
|
||||
assert d["file_path"] == "/test.py"
|
||||
assert d["line_start"] == 10
|
||||
assert d["line_end"] == 20
|
||||
assert d["signature"] == "def func():"
|
||||
assert d["documentation"] == "Test doc"
|
||||
|
||||
def test_to_dict_excludes_none(self):
|
||||
"""SymbolInfo.to_dict() excludes None fields."""
|
||||
info = SymbolInfo(
|
||||
name="func",
|
||||
kind="function",
|
||||
file_path="/test.py",
|
||||
line_start=10,
|
||||
line_end=20,
|
||||
)
|
||||
d = info.to_dict()
|
||||
assert "signature" not in d
|
||||
assert "documentation" not in d
|
||||
assert "name" in d
|
||||
assert "kind" in d
|
||||
|
||||
def test_basic_creation(self):
|
||||
"""SymbolInfo can be created with required fields only."""
|
||||
info = SymbolInfo(
|
||||
name="MyClass",
|
||||
kind="class",
|
||||
file_path="/src/module.py",
|
||||
line_start=1,
|
||||
line_end=50,
|
||||
)
|
||||
assert info.name == "MyClass"
|
||||
assert info.kind == "class"
|
||||
assert info.signature is None
|
||||
assert info.documentation is None
|
||||
|
||||
|
||||
class TestReferenceInfo:
|
||||
"""Test ReferenceInfo dataclass."""
|
||||
|
||||
def test_to_dict(self):
|
||||
"""ReferenceInfo.to_dict() returns all fields."""
|
||||
ref = ReferenceInfo(
|
||||
file_path="/src/main.py",
|
||||
line=25,
|
||||
column=4,
|
||||
context="result = func()",
|
||||
relationship_type="call",
|
||||
)
|
||||
d = ref.to_dict()
|
||||
assert d["file_path"] == "/src/main.py"
|
||||
assert d["line"] == 25
|
||||
assert d["column"] == 4
|
||||
assert d["context"] == "result = func()"
|
||||
assert d["relationship_type"] == "call"
|
||||
|
||||
def test_all_fields_required(self):
|
||||
"""ReferenceInfo requires all fields."""
|
||||
ref = ReferenceInfo(
|
||||
file_path="/test.py",
|
||||
line=10,
|
||||
column=0,
|
||||
context="import module",
|
||||
relationship_type="import",
|
||||
)
|
||||
assert ref.file_path == "/test.py"
|
||||
assert ref.relationship_type == "import"
|
||||
|
||||
|
||||
class TestRelatedSymbol:
|
||||
"""Test RelatedSymbol dataclass."""
|
||||
|
||||
def test_to_dict_includes_all_fields(self):
|
||||
"""RelatedSymbol.to_dict() includes all non-None fields."""
|
||||
sym = RelatedSymbol(
|
||||
name="BaseClass",
|
||||
kind="class",
|
||||
relationship="inherits",
|
||||
file_path="/src/base.py",
|
||||
)
|
||||
d = sym.to_dict()
|
||||
assert d["name"] == "BaseClass"
|
||||
assert d["kind"] == "class"
|
||||
assert d["relationship"] == "inherits"
|
||||
assert d["file_path"] == "/src/base.py"
|
||||
|
||||
def test_to_dict_excludes_none(self):
|
||||
"""RelatedSymbol.to_dict() excludes None file_path."""
|
||||
sym = RelatedSymbol(
|
||||
name="helper",
|
||||
kind="function",
|
||||
relationship="calls",
|
||||
)
|
||||
d = sym.to_dict()
|
||||
assert "file_path" not in d
|
||||
assert d["name"] == "helper"
|
||||
assert d["relationship"] == "calls"
|
||||
|
||||
|
||||
class TestMCPContext:
|
||||
"""Test MCPContext dataclass."""
|
||||
|
||||
def test_to_dict_basic(self):
|
||||
"""MCPContext.to_dict() returns basic structure."""
|
||||
ctx = MCPContext(context_type="test")
|
||||
d = ctx.to_dict()
|
||||
assert d["version"] == "1.0"
|
||||
assert d["context_type"] == "test"
|
||||
assert d["metadata"] == {}
|
||||
|
||||
def test_to_dict_with_symbol(self):
|
||||
"""MCPContext.to_dict() includes symbol when present."""
|
||||
ctx = MCPContext(
|
||||
context_type="test",
|
||||
symbol=SymbolInfo("f", "function", "/t.py", 1, 2),
|
||||
)
|
||||
d = ctx.to_dict()
|
||||
assert "symbol" in d
|
||||
assert d["symbol"]["name"] == "f"
|
||||
assert d["symbol"]["kind"] == "function"
|
||||
|
||||
def test_to_dict_with_references(self):
|
||||
"""MCPContext.to_dict() includes references when present."""
|
||||
ctx = MCPContext(
|
||||
context_type="test",
|
||||
references=[
|
||||
ReferenceInfo("/a.py", 10, 0, "call()", "call"),
|
||||
ReferenceInfo("/b.py", 20, 5, "import x", "import"),
|
||||
],
|
||||
)
|
||||
d = ctx.to_dict()
|
||||
assert "references" in d
|
||||
assert len(d["references"]) == 2
|
||||
assert d["references"][0]["line"] == 10
|
||||
|
||||
def test_to_dict_with_related_symbols(self):
|
||||
"""MCPContext.to_dict() includes related_symbols when present."""
|
||||
ctx = MCPContext(
|
||||
context_type="test",
|
||||
related_symbols=[
|
||||
RelatedSymbol("Base", "class", "inherits"),
|
||||
RelatedSymbol("helper", "function", "calls"),
|
||||
],
|
||||
)
|
||||
d = ctx.to_dict()
|
||||
assert "related_symbols" in d
|
||||
assert len(d["related_symbols"]) == 2
|
||||
|
||||
def test_to_json(self):
|
||||
"""MCPContext.to_json() returns valid JSON."""
|
||||
ctx = MCPContext(context_type="test")
|
||||
j = ctx.to_json()
|
||||
parsed = json.loads(j)
|
||||
assert parsed["version"] == "1.0"
|
||||
assert parsed["context_type"] == "test"
|
||||
|
||||
def test_to_json_with_indent(self):
|
||||
"""MCPContext.to_json() respects indent parameter."""
|
||||
ctx = MCPContext(context_type="test")
|
||||
j = ctx.to_json(indent=4)
|
||||
# Check it's properly indented
|
||||
assert " " in j
|
||||
|
||||
def test_to_prompt_injection_basic(self):
|
||||
"""MCPContext.to_prompt_injection() returns formatted string."""
|
||||
ctx = MCPContext(
|
||||
symbol=SymbolInfo("my_func", "function", "/test.py", 10, 20),
|
||||
definition="def my_func(): pass",
|
||||
)
|
||||
prompt = ctx.to_prompt_injection()
|
||||
assert "<code_context>" in prompt
|
||||
assert "my_func" in prompt
|
||||
assert "def my_func()" in prompt
|
||||
assert "</code_context>" in prompt
|
||||
|
||||
def test_to_prompt_injection_with_references(self):
|
||||
"""MCPContext.to_prompt_injection() includes references."""
|
||||
ctx = MCPContext(
|
||||
symbol=SymbolInfo("func", "function", "/test.py", 1, 5),
|
||||
references=[
|
||||
ReferenceInfo("/a.py", 10, 0, "func()", "call"),
|
||||
ReferenceInfo("/b.py", 20, 0, "from x import func", "import"),
|
||||
],
|
||||
)
|
||||
prompt = ctx.to_prompt_injection()
|
||||
assert "References (2 found)" in prompt
|
||||
assert "/a.py:10" in prompt
|
||||
assert "call" in prompt
|
||||
|
||||
def test_to_prompt_injection_limits_references(self):
|
||||
"""MCPContext.to_prompt_injection() limits references to 5."""
|
||||
refs = [
|
||||
ReferenceInfo(f"/file{i}.py", i, 0, f"ref{i}", "call")
|
||||
for i in range(10)
|
||||
]
|
||||
ctx = MCPContext(
|
||||
symbol=SymbolInfo("func", "function", "/test.py", 1, 5),
|
||||
references=refs,
|
||||
)
|
||||
prompt = ctx.to_prompt_injection()
|
||||
# Should show "10 found" but only include 5
|
||||
assert "References (10 found)" in prompt
|
||||
assert "/file0.py" in prompt
|
||||
assert "/file4.py" in prompt
|
||||
assert "/file5.py" not in prompt
|
||||
|
||||
def test_to_prompt_injection_with_related_symbols(self):
|
||||
"""MCPContext.to_prompt_injection() includes related symbols."""
|
||||
ctx = MCPContext(
|
||||
symbol=SymbolInfo("MyClass", "class", "/test.py", 1, 50),
|
||||
related_symbols=[
|
||||
RelatedSymbol("BaseClass", "class", "inherits"),
|
||||
RelatedSymbol("helper", "function", "calls"),
|
||||
],
|
||||
)
|
||||
prompt = ctx.to_prompt_injection()
|
||||
assert "Related Symbols" in prompt
|
||||
assert "BaseClass (inherits)" in prompt
|
||||
assert "helper (calls)" in prompt
|
||||
|
||||
def test_to_prompt_injection_limits_related_symbols(self):
|
||||
"""MCPContext.to_prompt_injection() limits related symbols to 10."""
|
||||
related = [
|
||||
RelatedSymbol(f"sym{i}", "function", "calls")
|
||||
for i in range(15)
|
||||
]
|
||||
ctx = MCPContext(
|
||||
symbol=SymbolInfo("func", "function", "/test.py", 1, 5),
|
||||
related_symbols=related,
|
||||
)
|
||||
prompt = ctx.to_prompt_injection()
|
||||
assert "sym0 (calls)" in prompt
|
||||
assert "sym9 (calls)" in prompt
|
||||
assert "sym10 (calls)" not in prompt
|
||||
|
||||
def test_empty_context(self):
|
||||
"""MCPContext works with minimal data."""
|
||||
ctx = MCPContext()
|
||||
d = ctx.to_dict()
|
||||
assert d["version"] == "1.0"
|
||||
assert d["context_type"] == "code_context"
|
||||
|
||||
prompt = ctx.to_prompt_injection()
|
||||
assert "<code_context>" in prompt
|
||||
assert "</code_context>" in prompt
|
||||
|
||||
def test_metadata_preserved(self):
|
||||
"""MCPContext preserves custom metadata."""
|
||||
ctx = MCPContext(
|
||||
context_type="custom",
|
||||
metadata={
|
||||
"source": "codex-lens",
|
||||
"indexed_at": "2024-01-01",
|
||||
"custom_key": "custom_value",
|
||||
},
|
||||
)
|
||||
d = ctx.to_dict()
|
||||
assert d["metadata"]["source"] == "codex-lens"
|
||||
assert d["metadata"]["custom_key"] == "custom_value"
|
||||
Reference in New Issue
Block a user