From ca7bf5f43c43cf92396e21274b1bdd7e497aa7bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 09:20:09 +0000 Subject: [PATCH 1/3] Initial plan From 13e81ec3f80c55e4585921934d61c7ac6697bba4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 09:23:18 +0000 Subject: [PATCH 2/3] fix: address PR review comments for Relation API - Fix documentation: Remove incorrect max_depth field from RelationResponse - Fix BFS bug: Move target check before visited.add() to find all paths - Fix BFS optimization: Check visited before adding to queue Co-authored-by: observerw <20661574+observerw@users.noreply.github.com> --- docs/schemas/draft/relation.md | 3 ++- src/lsap/capability/relation.py | 19 +++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/schemas/draft/relation.md b/docs/schemas/draft/relation.md index cb25769..6b49e96 100644 --- a/docs/schemas/draft/relation.md +++ b/docs/schemas/draft/relation.md @@ -24,7 +24,8 @@ This is a high-value query for: | `source` | `ChainNode` | The resolved source symbol. | | `target` | `ChainNode` | The resolved target symbol. | | `chains` | `ChainNode[][]` | All paths found. Each path is a sequence of nodes. | -| `max_depth` | `number` | The maximum depth used for the search. | + +The maximum depth used for the search is available as `request.max_depth`, since the response includes the original `RelationRequest`. ### ChainNode diff --git a/src/lsap/capability/relation.py b/src/lsap/capability/relation.py index 77b2471..bc7af69 100644 --- a/src/lsap/capability/relation.py +++ b/src/lsap/capability/relation.py @@ -112,16 +112,16 @@ async def _find_paths( if depth >= max_depth: continue - # Skip if already visited (cycle detection) - if current_key in visited: - continue - visited.add(current_key) - # Check if we've reached the target if current_key == target_key: found_chains.append(path) continue + # Skip if already visited (cycle detection) + if current_key in visited: + continue + visited.add(current_key) + # Get outgoing calls from current item outgoing_calls = ( await call_hierarchy._request_call_hierarchy_outgoing_calls( @@ -135,9 +135,12 @@ async def _find_paths( # Add each outgoing call to the queue for call in outgoing_calls: next_item = call.to - next_node = self._to_chain_node(next_item) - next_path = path + [next_node] - queue.append((next_item, next_path, depth + 1)) + next_key = self._item_key(next_item) + # Skip if already visited to prevent redundant queue entries + if next_key not in visited: + next_node = self._to_chain_node(next_item) + next_path = path + [next_node] + queue.append((next_item, next_path, depth + 1)) return found_chains From 452b5977c91b4632e37f341c5710568817598c81 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 09:27:25 +0000 Subject: [PATCH 3/3] test: add comprehensive integration tests for RelationCapability - Add mock LSP client with call hierarchy and document symbol support - Test single path discovery - Test multiple paths between symbols - Test scenario with no path - Test max_depth boundary conditions - Test cycle detection with recursive calls - Test direct calls - Test paths of different lengths Co-authored-by: observerw <20661574+observerw@users.noreply.github.com> --- src/lsap/capability/relation.py | 8 +- tests/test_relation.py | 421 +++++++++++++++++++++++++++++++- 2 files changed, 424 insertions(+), 5 deletions(-) diff --git a/src/lsap/capability/relation.py b/src/lsap/capability/relation.py index bc7af69..14ec234 100644 --- a/src/lsap/capability/relation.py +++ b/src/lsap/capability/relation.py @@ -108,15 +108,15 @@ async def _find_paths( current_item, path, depth = queue.popleft() current_key = self._item_key(current_item) - # Skip if we've exceeded max depth - if depth >= max_depth: - continue - # Check if we've reached the target if current_key == target_key: found_chains.append(path) continue + # Skip if we've exceeded max depth + if depth >= max_depth: + continue + # Skip if already visited (cycle detection) if current_key in visited: continue diff --git a/tests/test_relation.py b/tests/test_relation.py index f5b6668..55bb980 100644 --- a/tests/test_relation.py +++ b/tests/test_relation.py @@ -5,7 +5,35 @@ """ from pathlib import Path - +from contextlib import asynccontextmanager + +import pytest +from lsprotocol.types import ( + CallHierarchyIncomingCall, + CallHierarchyItem, + CallHierarchyOutgoingCall, + CallHierarchyOutgoingCallsParams, + DocumentSymbol, + SymbolKind, +) +from lsprotocol.types import Position as LSPPosition +from lsprotocol.types import Range as LSPRange +from lsp_client.capability.request import ( + WithRequestCallHierarchy, + WithRequestDocumentSymbol, +) +from lsp_client.client.document_state import DocumentStateManager +from lsp_client.protocol import CapabilityClientProtocol +from lsp_client.protocol.lang import LanguageConfig +from lsp_client.utils.config import ConfigurationMap +from lsp_client.utils.workspace import ( + DEFAULT_WORKSPACE_DIR, + Workspace, + WorkspaceFolder, +) +from lsprotocol.types import LanguageKind + +from lsap.capability.relation import RelationCapability from lsap.schema.locate import Locate, SymbolScope from lsap.schema.relation import ChainNode, RelationRequest, RelationResponse @@ -397,3 +425,394 @@ def test_relation_request_with_nested_symbol_path(): assert req.source.scope.symbol_path == ["UserService", "get_user"] assert req.target.scope.symbol_path == ["Database", "query"] assert req.max_depth == 3 + + +# ============================================================================ +# Integration tests with mock LSP client +# ============================================================================ + + +class MockRelationClient( + WithRequestCallHierarchy, WithRequestDocumentSymbol, CapabilityClientProtocol +): + """Mock client for testing RelationCapability with call hierarchy support.""" + + def __init__(self, call_graph: dict[str, list[str]] | None = None): + """ + Initialize mock client with a call graph. + + call_graph: Dictionary mapping symbol names to list of symbols they call. + Example: {"A": ["B", "C"], "B": ["D"]} means A calls B and C, B calls D. + """ + self.call_graph = call_graph or {} + self._workspace = Workspace( + { + DEFAULT_WORKSPACE_DIR: WorkspaceFolder( + uri=Path.cwd().as_uri(), + name=DEFAULT_WORKSPACE_DIR, + ) + } + ) + self._config_map = ConfigurationMap() + self._doc_state = DocumentStateManager() + + def from_uri(self, uri: str, *, relative: bool = True) -> Path: + return Path(uri.replace("file://", "")) + + def get_workspace(self) -> Workspace: + return self._workspace + + def get_config_map(self) -> ConfigurationMap: + return self._config_map + + def get_document_state(self) -> DocumentStateManager: + return self._doc_state + + @classmethod + def get_language_config(cls): + return LanguageConfig( + kind=LanguageKind.Python, + suffixes=["py"], + project_files=["pyproject.toml"], + ) + + async def request(self, req, schema): + return None + + async def notify(self, msg): + pass + + async def read_file(self, file_path) -> str: + return "# Mock file content" + + async def write_file(self, uri: str, content: str) -> None: + pass + + @asynccontextmanager + async def open_files(self, *file_paths): + yield + + async def request_document_symbol_list( + self, file_path: Path + ) -> list[DocumentSymbol]: + """Mock document symbol list - returns a single function symbol based on file name.""" + # Extract symbol name from file path (e.g., test_A.py -> A) + name = file_path.stem.replace("test_", "") + if name in self.call_graph or any( + name in calls for calls in self.call_graph.values() + ): + return [ + DocumentSymbol( + name=name, + kind=SymbolKind.Function, + range=LSPRange( + start=LSPPosition(line=0, character=0), + end=LSPPosition(line=1, character=0), + ), + selection_range=LSPRange( + start=LSPPosition(line=0, character=4), + end=LSPPosition(line=0, character=4 + len(name)), + ), + ) + ] + return [] + + async def request_document_symbol_information_list(self, file_path): + return [] + + def _make_call_hierarchy_item(self, name: str) -> CallHierarchyItem: + """Create a mock CallHierarchyItem for a symbol name.""" + return CallHierarchyItem( + name=name, + kind=SymbolKind.Function, + uri=f"file://test_{name}.py", + range=LSPRange( + start=LSPPosition(line=0, character=0), + end=LSPPosition(line=1, character=0), + ), + selection_range=LSPRange( + start=LSPPosition(line=0, character=4), + end=LSPPosition(line=0, character=4 + len(name)), + ), + ) + + async def prepare_call_hierarchy( + self, file_path: Path, position: LSPPosition + ) -> list[CallHierarchyItem] | None: + """Mock prepare_call_hierarchy - returns item based on file path.""" + # Extract symbol name from file path (e.g., test_A.py -> A) + name = file_path.stem.replace("test_", "") + if name in self.call_graph or any( + name in calls for calls in self.call_graph.values() + ): + return [self._make_call_hierarchy_item(name)] + return None + + async def _request_call_hierarchy_outgoing_calls( + self, params: CallHierarchyOutgoingCallsParams + ) -> list[CallHierarchyOutgoingCall] | None: + """Mock outgoing calls based on the call graph.""" + item_name = params.item.name + if item_name not in self.call_graph: + return [] + + outgoing = [] + for target_name in self.call_graph[item_name]: + target_item = self._make_call_hierarchy_item(target_name) + outgoing.append( + CallHierarchyOutgoingCall( + to=target_item, + from_ranges=[ + LSPRange( + start=LSPPosition(line=0, character=0), + end=LSPPosition(line=0, character=1), + ) + ], + ) + ) + return outgoing + + +@pytest.mark.asyncio +async def test_relation_capability_single_path(): + """Test finding a single path between two symbols.""" + # Call graph: A -> B -> C + call_graph = {"A": ["B"], "B": ["C"]} + client = MockRelationClient(call_graph) + capability = RelationCapability(client=client) # type: ignore + + req = RelationRequest( + source=Locate( + file_path=Path("test_A.py"), + scope=SymbolScope(symbol_path=["A"]), + ), + target=Locate( + file_path=Path("test_C.py"), + scope=SymbolScope(symbol_path=["C"]), + ), + max_depth=5, + ) + + resp = await capability(req) + assert resp is not None + assert resp.source.name == "A" + assert resp.target.name == "C" + assert len(resp.chains) == 1 + assert len(resp.chains[0]) == 3 # A -> B -> C + assert resp.chains[0][0].name == "A" + assert resp.chains[0][1].name == "B" + assert resp.chains[0][2].name == "C" + + +@pytest.mark.asyncio +async def test_relation_capability_multiple_paths(): + """Test finding multiple paths between two symbols.""" + # Call graph: A -> B -> D, A -> C -> D (two paths from A to D) + call_graph = {"A": ["B", "C"], "B": ["D"], "C": ["D"]} + client = MockRelationClient(call_graph) + capability = RelationCapability(client=client) # type: ignore + + req = RelationRequest( + source=Locate( + file_path=Path("test_A.py"), + scope=SymbolScope(symbol_path=["A"]), + ), + target=Locate( + file_path=Path("test_D.py"), + scope=SymbolScope(symbol_path=["D"]), + ), + max_depth=5, + ) + + resp = await capability(req) + assert resp is not None + assert resp.source.name == "A" + assert resp.target.name == "D" + assert len(resp.chains) == 2 # Two paths: A->B->D and A->C->D + + # Both chains should start with A and end with D + for chain in resp.chains: + assert chain[0].name == "A" + assert chain[-1].name == "D" + assert len(chain) == 3 # A -> (B or C) -> D + + # Check that we have both paths + middle_nodes = {chain[1].name for chain in resp.chains} + assert middle_nodes == {"B", "C"} + + +@pytest.mark.asyncio +async def test_relation_capability_no_path(): + """Test when no path exists between source and target.""" + # Call graph: A -> B, C -> D (no connection from A to D) + call_graph = {"A": ["B"], "C": ["D"]} + client = MockRelationClient(call_graph) + capability = RelationCapability(client=client) # type: ignore + + req = RelationRequest( + source=Locate( + file_path=Path("test_A.py"), + scope=SymbolScope(symbol_path=["A"]), + ), + target=Locate( + file_path=Path("test_D.py"), + scope=SymbolScope(symbol_path=["D"]), + ), + max_depth=5, + ) + + resp = await capability(req) + assert resp is not None + assert resp.source.name == "A" + assert resp.target.name == "D" + assert len(resp.chains) == 0 + + +@pytest.mark.asyncio +async def test_relation_capability_max_depth(): + """Test that max_depth is respected.""" + # Call graph: A -> B -> C -> D -> E (chain of 4 calls) + call_graph = {"A": ["B"], "B": ["C"], "C": ["D"], "D": ["E"]} + client = MockRelationClient(call_graph) + capability = RelationCapability(client=client) # type: ignore + + # With max_depth=3, we can reach D (3 hops: A->B, B->C, C->D) + req = RelationRequest( + source=Locate( + file_path=Path("test_A.py"), + scope=SymbolScope(symbol_path=["A"]), + ), + target=Locate( + file_path=Path("test_C.py"), + scope=SymbolScope(symbol_path=["C"]), + ), + max_depth=3, + ) + + resp = await capability(req) + assert resp is not None + assert len(resp.chains) == 1 + assert len(resp.chains[0]) == 3 # A -> B -> C + + # With max_depth=2, we can reach C (2 hops: A->B, B->C) + req2 = RelationRequest( + source=Locate( + file_path=Path("test_A.py"), + scope=SymbolScope(symbol_path=["A"]), + ), + target=Locate( + file_path=Path("test_C.py"), + scope=SymbolScope(symbol_path=["C"]), + ), + max_depth=2, + ) + + resp2 = await capability(req2) + assert resp2 is not None + assert len(resp2.chains) == 1 + + # With max_depth=1, we cannot reach C (only B is reachable) + req3 = RelationRequest( + source=Locate( + file_path=Path("test_A.py"), + scope=SymbolScope(symbol_path=["A"]), + ), + target=Locate( + file_path=Path("test_C.py"), + scope=SymbolScope(symbol_path=["C"]), + ), + max_depth=1, + ) + + resp3 = await capability(req3) + assert resp3 is not None + assert len(resp3.chains) == 0 # Cannot reach C within max_depth=1 + + +@pytest.mark.asyncio +async def test_relation_capability_cycle_detection(): + """Test that cycles are properly detected and don't cause infinite loops.""" + # Call graph with cycle: A -> B -> C -> B (cycle between B and C) + # But there's also A -> B -> D (path without cycle) + call_graph = {"A": ["B"], "B": ["C", "D"], "C": ["B"]} + client = MockRelationClient(call_graph) + capability = RelationCapability(client=client) # type: ignore + + req = RelationRequest( + source=Locate( + file_path=Path("test_A.py"), + scope=SymbolScope(symbol_path=["A"]), + ), + target=Locate( + file_path=Path("test_D.py"), + scope=SymbolScope(symbol_path=["D"]), + ), + max_depth=10, + ) + + resp = await capability(req) + assert resp is not None + assert resp.source.name == "A" + assert resp.target.name == "D" + assert len(resp.chains) == 1 # Should find A -> B -> D + assert len(resp.chains[0]) == 3 + assert resp.chains[0][0].name == "A" + assert resp.chains[0][1].name == "B" + assert resp.chains[0][2].name == "D" + + +@pytest.mark.asyncio +async def test_relation_capability_direct_call(): + """Test direct call (source directly calls target).""" + # Call graph: A -> B (direct call) + call_graph = {"A": ["B"]} + client = MockRelationClient(call_graph) + capability = RelationCapability(client=client) # type: ignore + + req = RelationRequest( + source=Locate( + file_path=Path("test_A.py"), + scope=SymbolScope(symbol_path=["A"]), + ), + target=Locate( + file_path=Path("test_B.py"), + scope=SymbolScope(symbol_path=["B"]), + ), + max_depth=5, + ) + + resp = await capability(req) + assert resp is not None + assert len(resp.chains) == 1 + assert len(resp.chains[0]) == 2 # Just A -> B + assert resp.chains[0][0].name == "A" + assert resp.chains[0][1].name == "B" + + +@pytest.mark.asyncio +async def test_relation_capability_different_path_lengths(): + """Test finding paths of different lengths.""" + # Call graph: A -> D (direct), A -> B -> D (2 hops), A -> C -> E -> D (3 hops) + call_graph = {"A": ["B", "C", "D"], "B": ["D"], "C": ["E"], "E": ["D"]} + client = MockRelationClient(call_graph) + capability = RelationCapability(client=client) # type: ignore + + req = RelationRequest( + source=Locate( + file_path=Path("test_A.py"), + scope=SymbolScope(symbol_path=["A"]), + ), + target=Locate( + file_path=Path("test_D.py"), + scope=SymbolScope(symbol_path=["D"]), + ), + max_depth=5, + ) + + resp = await capability(req) + assert resp is not None + assert len(resp.chains) == 3 # Three paths of different lengths + + # Check path lengths + path_lengths = sorted([len(chain) for chain in resp.chains]) + assert path_lengths == [2, 3, 4] # Direct, 2-hop, and 3-hop paths