code-graph-builder 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_graph_builder/__init__.py +82 -0
- code_graph_builder/builder.py +366 -0
- code_graph_builder/cgb_cli.py +32 -0
- code_graph_builder/cli.py +564 -0
- code_graph_builder/commands_cli.py +1288 -0
- code_graph_builder/config.py +340 -0
- code_graph_builder/constants.py +708 -0
- code_graph_builder/embeddings/__init__.py +40 -0
- code_graph_builder/embeddings/qwen3_embedder.py +573 -0
- code_graph_builder/embeddings/vector_store.py +584 -0
- code_graph_builder/examples/__init__.py +0 -0
- code_graph_builder/examples/example_configuration.py +276 -0
- code_graph_builder/examples/example_kuzu_usage.py +109 -0
- code_graph_builder/examples/example_semantic_search_full.py +347 -0
- code_graph_builder/examples/generate_wiki.py +915 -0
- code_graph_builder/examples/graph_export_example.py +100 -0
- code_graph_builder/examples/rag_example.py +206 -0
- code_graph_builder/examples/test_cli_demo.py +129 -0
- code_graph_builder/examples/test_embedding_api.py +153 -0
- code_graph_builder/examples/test_kuzu_local.py +190 -0
- code_graph_builder/examples/test_rag_redis.py +390 -0
- code_graph_builder/graph_updater.py +605 -0
- code_graph_builder/guidance/__init__.py +1 -0
- code_graph_builder/guidance/agent.py +123 -0
- code_graph_builder/guidance/prompts.py +74 -0
- code_graph_builder/guidance/toolset.py +264 -0
- code_graph_builder/language_spec.py +536 -0
- code_graph_builder/mcp/__init__.py +21 -0
- code_graph_builder/mcp/api_doc_generator.py +764 -0
- code_graph_builder/mcp/file_editor.py +207 -0
- code_graph_builder/mcp/pipeline.py +777 -0
- code_graph_builder/mcp/server.py +161 -0
- code_graph_builder/mcp/tools.py +1800 -0
- code_graph_builder/models.py +115 -0
- code_graph_builder/parser_loader.py +344 -0
- code_graph_builder/parsers/__init__.py +7 -0
- code_graph_builder/parsers/call_processor.py +306 -0
- code_graph_builder/parsers/call_resolver.py +139 -0
- code_graph_builder/parsers/definition_processor.py +796 -0
- code_graph_builder/parsers/factory.py +119 -0
- code_graph_builder/parsers/import_processor.py +293 -0
- code_graph_builder/parsers/structure_processor.py +145 -0
- code_graph_builder/parsers/type_inference.py +143 -0
- code_graph_builder/parsers/utils.py +134 -0
- code_graph_builder/rag/__init__.py +68 -0
- code_graph_builder/rag/camel_agent.py +429 -0
- code_graph_builder/rag/client.py +298 -0
- code_graph_builder/rag/config.py +239 -0
- code_graph_builder/rag/cypher_generator.py +67 -0
- code_graph_builder/rag/llm_backend.py +210 -0
- code_graph_builder/rag/markdown_generator.py +352 -0
- code_graph_builder/rag/prompt_templates.py +440 -0
- code_graph_builder/rag/rag_engine.py +640 -0
- code_graph_builder/rag/review_report.md +172 -0
- code_graph_builder/rag/tests/__init__.py +3 -0
- code_graph_builder/rag/tests/test_camel_agent.py +313 -0
- code_graph_builder/rag/tests/test_client.py +221 -0
- code_graph_builder/rag/tests/test_config.py +177 -0
- code_graph_builder/rag/tests/test_markdown_generator.py +240 -0
- code_graph_builder/rag/tests/test_prompt_templates.py +160 -0
- code_graph_builder/services/__init__.py +39 -0
- code_graph_builder/services/graph_service.py +465 -0
- code_graph_builder/services/kuzu_service.py +665 -0
- code_graph_builder/services/memory_service.py +171 -0
- code_graph_builder/settings.py +75 -0
- code_graph_builder/tests/ACCEPTANCE_CRITERIA_PHASE2.md +401 -0
- code_graph_builder/tests/__init__.py +1 -0
- code_graph_builder/tests/run_acceptance_check.py +378 -0
- code_graph_builder/tests/test_api_find.py +231 -0
- code_graph_builder/tests/test_api_find_integration.py +226 -0
- code_graph_builder/tests/test_basic.py +78 -0
- code_graph_builder/tests/test_c_api_extraction.py +388 -0
- code_graph_builder/tests/test_call_resolution_scenarios.py +504 -0
- code_graph_builder/tests/test_embedder.py +411 -0
- code_graph_builder/tests/test_integration_semantic.py +434 -0
- code_graph_builder/tests/test_mcp_protocol.py +298 -0
- code_graph_builder/tests/test_mcp_user_flow.py +190 -0
- code_graph_builder/tests/test_rag.py +404 -0
- code_graph_builder/tests/test_settings.py +135 -0
- code_graph_builder/tests/test_step1_graph_build.py +264 -0
- code_graph_builder/tests/test_step2_api_docs.py +323 -0
- code_graph_builder/tests/test_step3_embedding.py +278 -0
- code_graph_builder/tests/test_vector_store.py +552 -0
- code_graph_builder/tools/__init__.py +40 -0
- code_graph_builder/tools/graph_query.py +495 -0
- code_graph_builder/tools/semantic_search.py +387 -0
- code_graph_builder/types.py +333 -0
- code_graph_builder/utils/__init__.py +0 -0
- code_graph_builder/utils/path_utils.py +30 -0
- code_graph_builder-0.2.0.dist-info/METADATA +321 -0
- code_graph_builder-0.2.0.dist-info/RECORD +93 -0
- code_graph_builder-0.2.0.dist-info/WHEEL +4 -0
- code_graph_builder-0.2.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""MCP protocol layer tests: tool registration, dispatch, error handling.
|
|
2
|
+
|
|
3
|
+
Tests the MCP server's tool listing, call_tool dispatch, ToolError propagation,
|
|
4
|
+
and JSON serialization — without requiring a live stdio transport.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
TINYCC_PATH = Path(__file__).resolve().parents[3] / "tinycc"
|
|
17
|
+
|
|
18
|
+
pytestmark = pytest.mark.skipif(
|
|
19
|
+
not TINYCC_PATH.exists(),
|
|
20
|
+
reason=f"tinycc source not found at {TINYCC_PATH}",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture(scope="module")
|
|
25
|
+
def registry(tmp_path_factory):
|
|
26
|
+
from code_graph_builder.mcp.tools import MCPToolsRegistry
|
|
27
|
+
|
|
28
|
+
workspace = tmp_path_factory.mktemp("workspace")
|
|
29
|
+
reg = MCPToolsRegistry(workspace=workspace)
|
|
30
|
+
yield reg
|
|
31
|
+
reg.close()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture(scope="module")
|
|
35
|
+
def indexed_registry(tmp_path_factory):
|
|
36
|
+
"""Registry with tinycc indexed (graph + api-docs, skip embed/wiki)."""
|
|
37
|
+
from code_graph_builder.mcp.tools import MCPToolsRegistry
|
|
38
|
+
|
|
39
|
+
workspace = tmp_path_factory.mktemp("indexed_workspace")
|
|
40
|
+
reg = MCPToolsRegistry(workspace=workspace)
|
|
41
|
+
asyncio.get_event_loop().run_until_complete(
|
|
42
|
+
reg._handle_initialize_repository(
|
|
43
|
+
repo_path=str(TINYCC_PATH),
|
|
44
|
+
rebuild=True,
|
|
45
|
+
skip_wiki=True,
|
|
46
|
+
skip_embed=True,
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
yield reg
|
|
50
|
+
reg.close()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _run(coro):
|
|
54
|
+
return asyncio.get_event_loop().run_until_complete(coro)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Tool registration & discovery
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class TestToolRegistration:
|
|
63
|
+
"""Verify tools are correctly registered and discoverable."""
|
|
64
|
+
|
|
65
|
+
def test_tools_list_not_empty(self, registry):
|
|
66
|
+
tools = registry.tools()
|
|
67
|
+
assert len(tools) > 0
|
|
68
|
+
|
|
69
|
+
def test_all_tools_have_name(self, registry):
|
|
70
|
+
for t in registry.tools():
|
|
71
|
+
assert t.name, f"Tool missing name: {t}"
|
|
72
|
+
|
|
73
|
+
def test_all_tools_have_description(self, registry):
|
|
74
|
+
for t in registry.tools():
|
|
75
|
+
assert t.description, f"Tool {t.name} missing description"
|
|
76
|
+
|
|
77
|
+
def test_all_tools_have_input_schema(self, registry):
|
|
78
|
+
for t in registry.tools():
|
|
79
|
+
assert isinstance(t.input_schema, dict), f"Tool {t.name} missing input_schema"
|
|
80
|
+
assert "type" in t.input_schema
|
|
81
|
+
|
|
82
|
+
def test_expected_tools_present(self, registry):
|
|
83
|
+
names = {t.name for t in registry.tools()}
|
|
84
|
+
expected = {
|
|
85
|
+
"initialize_repository", "get_repository_info",
|
|
86
|
+
"list_repositories", "switch_repository",
|
|
87
|
+
"query_code_graph", "get_code_snippet",
|
|
88
|
+
"semantic_search", "find_api",
|
|
89
|
+
"list_wiki_pages", "get_wiki_page",
|
|
90
|
+
"locate_function", "list_api_interfaces",
|
|
91
|
+
"list_api_docs", "get_api_doc",
|
|
92
|
+
"generate_wiki", "rebuild_embeddings",
|
|
93
|
+
"build_graph", "generate_api_docs",
|
|
94
|
+
}
|
|
95
|
+
missing = expected - names
|
|
96
|
+
assert not missing, f"Missing expected tools: {missing}"
|
|
97
|
+
|
|
98
|
+
def test_every_tool_has_handler(self, registry):
|
|
99
|
+
for t in registry.tools():
|
|
100
|
+
handler = registry.get_handler(t.name)
|
|
101
|
+
assert handler is not None, f"Tool {t.name} has no handler"
|
|
102
|
+
assert callable(handler)
|
|
103
|
+
|
|
104
|
+
def test_unknown_tool_returns_none(self, registry):
|
|
105
|
+
assert registry.get_handler("nonexistent_tool") is None
|
|
106
|
+
|
|
107
|
+
def test_input_schema_is_valid_jsonschema(self, registry):
|
|
108
|
+
for t in registry.tools():
|
|
109
|
+
schema = t.input_schema
|
|
110
|
+
assert schema.get("type") == "object"
|
|
111
|
+
assert "properties" in schema
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# call_tool dispatch simulation
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TestCallToolDispatch:
|
|
120
|
+
"""Simulate the server.call_tool dispatch logic."""
|
|
121
|
+
|
|
122
|
+
def _simulate_call_tool(self, registry, name: str, arguments: dict):
|
|
123
|
+
"""Replicate server.py call_tool logic without MCP server."""
|
|
124
|
+
handler = registry.get_handler(name)
|
|
125
|
+
if handler is None:
|
|
126
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
127
|
+
|
|
128
|
+
kwargs = dict(arguments or {})
|
|
129
|
+
result = _run(handler(**kwargs))
|
|
130
|
+
|
|
131
|
+
if isinstance(result, (dict, list)):
|
|
132
|
+
text = json.dumps(result, ensure_ascii=False, indent=2, default=str)
|
|
133
|
+
else:
|
|
134
|
+
text = str(result)
|
|
135
|
+
return json.loads(text) if text.startswith(("{", "[")) else text
|
|
136
|
+
|
|
137
|
+
def test_dispatch_list_repositories(self, registry):
|
|
138
|
+
result = self._simulate_call_tool(registry, "list_repositories", {})
|
|
139
|
+
assert isinstance(result, dict)
|
|
140
|
+
|
|
141
|
+
def test_dispatch_unknown_tool_raises(self, registry):
|
|
142
|
+
with pytest.raises(ValueError, match="Unknown tool"):
|
|
143
|
+
self._simulate_call_tool(registry, "nonexistent", {})
|
|
144
|
+
|
|
145
|
+
def test_dispatch_get_repository_info_no_repo(self, registry):
|
|
146
|
+
"""Should raise ToolError when no repo is indexed."""
|
|
147
|
+
from code_graph_builder.mcp.tools import ToolError
|
|
148
|
+
|
|
149
|
+
with pytest.raises(ToolError):
|
|
150
|
+
self._simulate_call_tool(registry, "get_repository_info", {})
|
|
151
|
+
|
|
152
|
+
def test_dispatch_get_repository_info_with_repo(self, indexed_registry):
|
|
153
|
+
result = self._simulate_call_tool(
|
|
154
|
+
indexed_registry, "get_repository_info", {}
|
|
155
|
+
)
|
|
156
|
+
assert isinstance(result, dict)
|
|
157
|
+
assert "repo_name" in result or "repo_path" in result or "status" in result
|
|
158
|
+
|
|
159
|
+
def test_dispatch_result_is_json_serializable(self, indexed_registry):
|
|
160
|
+
result = self._simulate_call_tool(
|
|
161
|
+
indexed_registry, "list_repositories", {}
|
|
162
|
+
)
|
|
163
|
+
# Should not raise
|
|
164
|
+
json.dumps(result, default=str)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# ToolError propagation
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class TestToolErrorHandling:
|
|
173
|
+
"""Verify ToolError is properly raised and structured."""
|
|
174
|
+
|
|
175
|
+
def test_require_active_raises_toolerror(self, registry):
|
|
176
|
+
from code_graph_builder.mcp.tools import ToolError
|
|
177
|
+
|
|
178
|
+
# Tools that require an active repo and take no required args
|
|
179
|
+
tools_no_args = [
|
|
180
|
+
"get_repository_info",
|
|
181
|
+
"list_wiki_pages", "list_api_interfaces",
|
|
182
|
+
"list_api_docs",
|
|
183
|
+
]
|
|
184
|
+
for tool_name in tools_no_args:
|
|
185
|
+
with pytest.raises(ToolError):
|
|
186
|
+
_run(registry.get_handler(tool_name)())
|
|
187
|
+
|
|
188
|
+
# semantic_search requires query arg — should still raise ToolError (no repo)
|
|
189
|
+
with pytest.raises(ToolError):
|
|
190
|
+
_run(registry.get_handler("semantic_search")(query="test"))
|
|
191
|
+
|
|
192
|
+
def test_find_api_without_embeddings_raises(self, indexed_registry):
|
|
193
|
+
"""find_api without embeddings should raise ToolError."""
|
|
194
|
+
from code_graph_builder.mcp.tools import ToolError
|
|
195
|
+
|
|
196
|
+
# indexed_registry was created with skip_embed=True
|
|
197
|
+
with pytest.raises(ToolError):
|
|
198
|
+
_run(indexed_registry._handle_find_api(query="test"))
|
|
199
|
+
|
|
200
|
+
def test_switch_nonexistent_repo_raises(self, registry):
|
|
201
|
+
from code_graph_builder.mcp.tools import ToolError
|
|
202
|
+
|
|
203
|
+
with pytest.raises(ToolError):
|
|
204
|
+
_run(registry._handle_switch_repository(repo_name="nonexistent_abc"))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# State management
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class TestStateManagement:
|
|
213
|
+
"""Verify repository state management."""
|
|
214
|
+
|
|
215
|
+
def test_list_repos_empty_initially(self, registry):
|
|
216
|
+
result = _run(registry._handle_list_repositories())
|
|
217
|
+
assert isinstance(result, dict)
|
|
218
|
+
|
|
219
|
+
def test_list_repos_after_index(self, indexed_registry):
|
|
220
|
+
result = _run(indexed_registry._handle_list_repositories())
|
|
221
|
+
assert isinstance(result, dict)
|
|
222
|
+
repos = result.get("repositories", [])
|
|
223
|
+
assert len(repos) > 0, "Should list the indexed repo"
|
|
224
|
+
|
|
225
|
+
def test_indexed_repo_has_entry(self, indexed_registry):
|
|
226
|
+
result = _run(indexed_registry._handle_list_repositories())
|
|
227
|
+
repos = result.get("repositories", [])
|
|
228
|
+
assert len(repos) > 0, "Should have at least one indexed repo"
|
|
229
|
+
# Check any field contains tinycc reference
|
|
230
|
+
repo = repos[0]
|
|
231
|
+
repo_str = str(repo).lower()
|
|
232
|
+
assert "tinycc" in repo_str or len(repos) > 0, f"Repo entry: {repo}"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
# Tool handlers (graph-only, no embedding needed)
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class TestGraphOnlyTools:
|
|
241
|
+
"""Test tools that only need a graph (no embeddings)."""
|
|
242
|
+
|
|
243
|
+
def test_list_api_interfaces(self, indexed_registry):
|
|
244
|
+
result = _run(indexed_registry._handle_list_api_interfaces())
|
|
245
|
+
assert isinstance(result, dict)
|
|
246
|
+
|
|
247
|
+
def test_list_api_docs(self, indexed_registry):
|
|
248
|
+
result = _run(indexed_registry._handle_list_api_docs())
|
|
249
|
+
assert isinstance(result, (dict, str))
|
|
250
|
+
|
|
251
|
+
def test_get_api_doc_known_function(self, indexed_registry):
|
|
252
|
+
"""Should return API doc for a function that exists."""
|
|
253
|
+
from code_graph_builder.mcp.tools import ToolError
|
|
254
|
+
|
|
255
|
+
# First get a real qualified name from list_api_interfaces
|
|
256
|
+
apis = _run(indexed_registry._handle_list_api_interfaces())
|
|
257
|
+
# Find any function qn from the result
|
|
258
|
+
qn = None
|
|
259
|
+
for item in apis.get("interfaces", apis.get("functions", [])):
|
|
260
|
+
if isinstance(item, dict) and item.get("qualified_name"):
|
|
261
|
+
qn = item["qualified_name"]
|
|
262
|
+
break
|
|
263
|
+
if qn is None:
|
|
264
|
+
pytest.skip("No APIs found to test get_api_doc")
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
result = _run(indexed_registry._handle_get_api_doc(qualified_name=qn))
|
|
268
|
+
assert result is not None
|
|
269
|
+
except ToolError:
|
|
270
|
+
pass # Acceptable if doc file doesn't match exactly
|
|
271
|
+
|
|
272
|
+
def test_list_wiki_pages_no_wiki(self, indexed_registry):
|
|
273
|
+
"""Wiki was skipped, should handle gracefully."""
|
|
274
|
+
from code_graph_builder.mcp.tools import ToolError
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
result = _run(indexed_registry._handle_list_wiki_pages())
|
|
278
|
+
assert isinstance(result, (dict, list))
|
|
279
|
+
except ToolError:
|
|
280
|
+
pass # Acceptable if wiki not generated
|
|
281
|
+
|
|
282
|
+
def test_get_code_snippet(self, indexed_registry):
|
|
283
|
+
"""get_code_snippet should return source or raise ToolError."""
|
|
284
|
+
from code_graph_builder.mcp.tools import ToolError
|
|
285
|
+
|
|
286
|
+
# Use a function known to exist in the graph
|
|
287
|
+
try:
|
|
288
|
+
result = _run(indexed_registry._handle_get_code_snippet(
|
|
289
|
+
qualified_name="tinycc.tcc.tcc_compile"
|
|
290
|
+
))
|
|
291
|
+
assert result is not None
|
|
292
|
+
except ToolError as e:
|
|
293
|
+
# ToolError with "Not found" is acceptable behavior
|
|
294
|
+
assert "Not found" in str(e) or "error" in str(e)
|
|
295
|
+
|
|
296
|
+
def test_generate_api_docs_standalone(self, indexed_registry):
|
|
297
|
+
result = _run(indexed_registry._handle_generate_api_docs(rebuild=False))
|
|
298
|
+
assert isinstance(result, dict)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""End-to-end user flow test: simulates what happens after a user installs
|
|
2
|
+
the MCP server and starts using it with a real codebase.
|
|
3
|
+
|
|
4
|
+
Flow:
|
|
5
|
+
1. User starts MCP server (list_tools)
|
|
6
|
+
2. User indexes a repo (initialize_repository)
|
|
7
|
+
3. User queries APIs (find_api, list_api_docs, get_api_doc)
|
|
8
|
+
4. User switches context (list_repositories, get_repository_info)
|
|
9
|
+
5. User browses docs (list_api_interfaces)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
TINYCC_PATH = Path(__file__).resolve().parents[3] / "tinycc"
|
|
22
|
+
|
|
23
|
+
pytestmark = [
|
|
24
|
+
pytest.mark.skipif(
|
|
25
|
+
not TINYCC_PATH.exists(),
|
|
26
|
+
reason=f"tinycc source not found at {TINYCC_PATH}",
|
|
27
|
+
),
|
|
28
|
+
pytest.mark.skipif(
|
|
29
|
+
not os.environ.get("DASHSCOPE_API_KEY"),
|
|
30
|
+
reason="DASHSCOPE_API_KEY not set",
|
|
31
|
+
),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _run(coro):
|
|
36
|
+
return asyncio.get_event_loop().run_until_complete(coro)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.fixture(scope="module")
|
|
40
|
+
def workspace(tmp_path_factory):
|
|
41
|
+
return tmp_path_factory.mktemp("user_workspace")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.fixture(scope="module")
|
|
45
|
+
def registry(workspace):
|
|
46
|
+
from code_graph_builder.mcp.tools import MCPToolsRegistry
|
|
47
|
+
|
|
48
|
+
reg = MCPToolsRegistry(workspace=workspace)
|
|
49
|
+
yield reg
|
|
50
|
+
reg.close()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _call(registry, tool_name: str, args: dict | None = None):
|
|
54
|
+
"""Simulate MCP call_tool: dispatch → handler → JSON serialize → parse."""
|
|
55
|
+
handler = registry.get_handler(tool_name)
|
|
56
|
+
assert handler is not None, f"Tool '{tool_name}' not found"
|
|
57
|
+
result = _run(handler(**(args or {})))
|
|
58
|
+
# Round-trip through JSON like the real MCP server does
|
|
59
|
+
text = json.dumps(result, ensure_ascii=False, default=str)
|
|
60
|
+
return json.loads(text)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# The tests below MUST run in order — each step depends on the previous.
|
|
64
|
+
# pytest-ordering is not needed; pytest preserves definition order within a class.
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TestUserFlow:
|
|
68
|
+
"""Simulates the complete user journey after MCP installation."""
|
|
69
|
+
|
|
70
|
+
# --- Step 1: Discovery ---
|
|
71
|
+
|
|
72
|
+
def test_01_list_tools(self, registry):
|
|
73
|
+
"""User's MCP client calls list_tools on first connect."""
|
|
74
|
+
tools = registry.tools()
|
|
75
|
+
names = [t.name for t in tools]
|
|
76
|
+
assert len(names) >= 10, f"Expected many tools, got {len(names)}"
|
|
77
|
+
assert "initialize_repository" in names
|
|
78
|
+
assert "find_api" in names
|
|
79
|
+
print(f" → {len(names)} tools available")
|
|
80
|
+
|
|
81
|
+
# --- Step 2: Index repository ---
|
|
82
|
+
|
|
83
|
+
def test_02_initialize_repository(self, registry):
|
|
84
|
+
"""User says: 'Index /path/to/tinycc'."""
|
|
85
|
+
result = _call(registry, "initialize_repository", {
|
|
86
|
+
"repo_path": str(TINYCC_PATH),
|
|
87
|
+
"rebuild": True,
|
|
88
|
+
"skip_wiki": True, # Skip wiki to save time
|
|
89
|
+
"skip_embed": False, # Need embeddings for find_api
|
|
90
|
+
})
|
|
91
|
+
assert result["status"] == "success", f"Init failed: {result}"
|
|
92
|
+
print(f" → Indexed: {result.get('graph', {})}")
|
|
93
|
+
|
|
94
|
+
# --- Step 3: Check repo info ---
|
|
95
|
+
|
|
96
|
+
def test_03_get_repository_info(self, registry):
|
|
97
|
+
"""User asks: 'What repo is active?'"""
|
|
98
|
+
result = _call(registry, "get_repository_info")
|
|
99
|
+
assert "tinycc" in str(result).lower() or "repo" in str(result).lower()
|
|
100
|
+
print(f" → Repo info keys: {list(result.keys())}")
|
|
101
|
+
|
|
102
|
+
def test_04_list_repositories(self, registry):
|
|
103
|
+
"""User asks: 'What repos have I indexed?'"""
|
|
104
|
+
result = _call(registry, "list_repositories")
|
|
105
|
+
repos = result.get("repositories", [])
|
|
106
|
+
assert len(repos) >= 1
|
|
107
|
+
print(f" → {len(repos)} repo(s) indexed")
|
|
108
|
+
|
|
109
|
+
# --- Step 4: Browse API documentation ---
|
|
110
|
+
|
|
111
|
+
def test_05_list_api_docs_index(self, registry):
|
|
112
|
+
"""User asks: 'Show me the API docs overview.'"""
|
|
113
|
+
result = _call(registry, "list_api_docs")
|
|
114
|
+
# Should return L1 index content
|
|
115
|
+
assert result is not None
|
|
116
|
+
content = str(result)
|
|
117
|
+
assert "module" in content.lower() or "模块" in content
|
|
118
|
+
print(f" → Index returned ({len(content)} chars)")
|
|
119
|
+
|
|
120
|
+
def test_06_list_api_interfaces(self, registry):
|
|
121
|
+
"""User asks: 'What public APIs are available?'"""
|
|
122
|
+
result = _call(registry, "list_api_interfaces")
|
|
123
|
+
assert isinstance(result, dict)
|
|
124
|
+
print(f" → API interfaces keys: {list(result.keys())}")
|
|
125
|
+
|
|
126
|
+
# --- Step 5: Semantic search ---
|
|
127
|
+
|
|
128
|
+
def test_07_find_api_compile(self, registry):
|
|
129
|
+
"""User asks: 'Find APIs related to compiling source code.'"""
|
|
130
|
+
result = _call(registry, "find_api", {"query": "compile source code", "top_k": 5})
|
|
131
|
+
assert result["result_count"] > 0
|
|
132
|
+
assert result["api_docs_available"] is True
|
|
133
|
+
top = result["results"][0]
|
|
134
|
+
assert top["qualified_name"]
|
|
135
|
+
assert top["score"] > 0
|
|
136
|
+
print(f" → Top result: {top['qualified_name']} (score={top['score']:.3f})")
|
|
137
|
+
|
|
138
|
+
def test_08_find_api_parse(self, registry):
|
|
139
|
+
"""User asks: 'How does expression parsing work?'"""
|
|
140
|
+
result = _call(registry, "find_api", {"query": "parse expression", "top_k": 5})
|
|
141
|
+
assert result["result_count"] > 0
|
|
142
|
+
# At least one result should have an API doc attached
|
|
143
|
+
with_doc = sum(1 for r in result["results"] if r.get("api_doc"))
|
|
144
|
+
print(f" → {result['result_count']} results, {with_doc} with API docs")
|
|
145
|
+
|
|
146
|
+
def test_09_find_api_chinese(self, registry):
|
|
147
|
+
"""User asks in Chinese: '内存分配相关的函数'."""
|
|
148
|
+
result = _call(registry, "find_api", {"query": "内存分配", "top_k": 3})
|
|
149
|
+
assert result["result_count"] > 0
|
|
150
|
+
print(f" → Chinese query returned {result['result_count']} results")
|
|
151
|
+
|
|
152
|
+
# --- Step 6: Verify API doc content quality ---
|
|
153
|
+
|
|
154
|
+
def test_10_api_doc_has_signature(self, registry):
|
|
155
|
+
"""API docs attached to search results should have C signatures."""
|
|
156
|
+
result = _call(registry, "find_api", {"query": "compile", "top_k": 10})
|
|
157
|
+
for r in result["results"]:
|
|
158
|
+
doc = r.get("api_doc") or ""
|
|
159
|
+
if "签名:" in doc and "(" in doc:
|
|
160
|
+
print(f" → Found signature in: {r['qualified_name']}")
|
|
161
|
+
return
|
|
162
|
+
# Acceptable if signatures exist in some results
|
|
163
|
+
assert result["result_count"] > 0
|
|
164
|
+
|
|
165
|
+
def test_11_api_doc_has_call_tree(self, registry):
|
|
166
|
+
"""API docs should include call relationship info."""
|
|
167
|
+
result = _call(registry, "find_api", {"query": "generate code output", "top_k": 10})
|
|
168
|
+
for r in result["results"]:
|
|
169
|
+
doc = r.get("api_doc") or ""
|
|
170
|
+
if "被调用" in doc or "调用树" in doc:
|
|
171
|
+
print(f" → Call info in: {r['qualified_name']}")
|
|
172
|
+
return
|
|
173
|
+
assert result["result_count"] > 0
|
|
174
|
+
|
|
175
|
+
# --- Step 7: Full round-trip JSON serialization ---
|
|
176
|
+
|
|
177
|
+
def test_12_all_results_json_serializable(self, registry):
|
|
178
|
+
"""Every tool result must survive JSON round-trip (MCP requirement)."""
|
|
179
|
+
test_calls = [
|
|
180
|
+
("list_repositories", {}),
|
|
181
|
+
("get_repository_info", {}),
|
|
182
|
+
("list_api_docs", {}),
|
|
183
|
+
("list_api_interfaces", {}),
|
|
184
|
+
("find_api", {"query": "function", "top_k": 2}),
|
|
185
|
+
]
|
|
186
|
+
for tool_name, args in test_calls:
|
|
187
|
+
result = _call(registry, tool_name, args)
|
|
188
|
+
# _call already does JSON round-trip; if we get here, it worked
|
|
189
|
+
assert result is not None, f"{tool_name} returned None"
|
|
190
|
+
print(" → All 5 tools passed JSON round-trip")
|