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:
catlog22
2026-01-17 19:20:24 +08:00
parent 1fae35c05d
commit f14418603a
137 changed files with 13125 additions and 301 deletions

View File

@@ -0,0 +1 @@
"""Tests for MCP (Model Context Protocol) module."""

View 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()

View 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)

View 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"