diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index f687968..192e024 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -2,7 +2,7 @@ > **Note:** This document is automatically generated and verified against the live test suite by `scripts/generate_requirements.py` and `tests/backend/test_requirements_sync.py`. -**Test Verification Baseline:** **1171 Automated Tests** (813 Pytest Backend + 308 Vitest Frontend + 50 Playwright E2E). +**Test Verification Baseline:** **1172 Automated Tests** (814 Pytest Backend + 308 Vitest Frontend + 50 Playwright E2E). --- @@ -749,11 +749,14 @@ Asserts both Markdown and PDF docs are matched under doc_type='doc'._ - `test_node_details_not_found` - _Test 404 response for invalid node id._ - `test_topology_performance_benchmark` - _Test performance benchmark ensuring graph construction of 500+ items executes quickly._ -#### `tests/backend/test_trace_path.py` (5 tests) +#### `tests/backend/test_trace_path.py` (6 tests) - `test_direct_and_mutual_recursion_termination` - `test_depth_clamping_and_limit_truncation` - `test_database_query_performance` - `test_trace_path_mcp_tool_execution` +- `test_cross_repo_boundary_resolution` - _Unit tests verifying cross-repo boundary resolution: +- Tracing a backend handler symbol in repo_b includes external_callers from repo_a via api_routes <-> api_client_calls matching. +- Tracing a client symbol in repo_a includes external_handlers in repo_b, while keeping AST BFS expansion scoped to repo_a._ - `test_trace_path_over_http_transport` #### `tests/backend/test_vector_store_base.py` (7 tests) diff --git a/app/services/search.py b/app/services/search.py index f1f9f64..edecd1b 100644 --- a/app/services/search.py +++ b/app/services/search.py @@ -5,9 +5,24 @@ logger = logging.getLogger('contextcortex.search') +import json from collections import deque from app.services.database import get_db_connection from app.services.git_manager import format_git_permalink +from app.services.chunking import match_route_and_call + +def _clean_path(filepath: Optional[str]) -> str: + if not filepath: + return "" + if "://" in filepath: + return filepath.split("://", 1)[1] + return filepath + +def _methods_compatible(m1: Optional[str], m2: Optional[str]) -> bool: + if not m1 or not m2: + return True + u1, u2 = m1.upper(), m2.upper() + return u1 == "ALL" or u2 == "ALL" or u1 == u2 def trace_symbol_path( symbol: str, @@ -19,11 +34,12 @@ def trace_symbol_path( """ Breadth-First Search (BFS) call graph traversal engine over ast_relationships. Supports direction ('callers', 'callees', 'both'), depth clamping (1-5), and total limit pagination. - Returns formatted ASCII / Markdown call tree detailing symbol hierarchies, file paths, line ranges, and permalinks. + Resolves cross-repository HTTP boundaries (api_routes <-> api_client_calls). + Returns deterministic, compact JSON string. """ target_sym = symbol.strip() if symbol else "" if not target_sym: - return "Error: target symbol cannot be empty." + return json.dumps({"error": "Target symbol cannot be empty."}, separators=(",", ":")) direction = (direction or "both").lower().strip() if direction not in ("callers", "callees", "both"): @@ -34,7 +50,7 @@ def trace_symbol_path( with get_db_connection() as conn: # Check if symbol exists in symbols or relationships - sym_query = "SELECT repo, filepath, name, full_symbol, kind, start_line, end_line FROM ast_symbols WHERE name = ? OR full_symbol = ?" + sym_query = "SELECT id, repo, filepath, name, full_symbol, kind, start_line, end_line FROM ast_symbols WHERE name = ? OR full_symbol = ?" sym_params = [target_sym, target_sym] if repo: sym_query += " AND repo = ?" @@ -49,46 +65,189 @@ def trace_symbol_path( [target_sym, target_sym, repo] if repo else [target_sym, target_sym] ).fetchone()[0] if rel_check == 0: - return f"No symbols or relationships found matching '{target_sym}'." + return json.dumps({"symbol": target_sym, "found": False, "callers": [], "callees": []}, separators=(",", ":")) # Fetch git repository info for permalink formatting git_repos = {} for r in conn.execute("SELECT name, url, commit_sha, provider FROM git_repositories").fetchall(): git_repos[r["name"]] = dict(r) - def get_permalink(r_repo: str, r_filepath: str, r_line: int) -> Optional[str]: + # Pre-load all routes and client calls across all repos to resolve boundaries efficiently + all_routes = [dict(r) for r in conn.execute("SELECT id, repo, filepath, framework, http_method, path_pattern, handler_symbol, start_line, end_line FROM api_routes").fetchall()] + all_calls = [dict(c) for c in conn.execute("SELECT id, repo, filepath, http_method, url_pattern, caller_symbol, line_number FROM api_client_calls").fetchall()] + + def get_permalink(r_repo: str, r_filepath: str, start_l: Optional[int], end_l: Optional[int]) -> Optional[str]: g_info = git_repos.get(r_repo) if not g_info: return None - rel_path = r_filepath - if "://" in rel_path: - rel_path = rel_path.split("://", 1)[1] + rel_path = _clean_path(r_filepath) return format_git_permalink( g_info["url"], g_info["commit_sha"], rel_path, - r_line, - r_line, + start_l, + end_l, provider=g_info.get("provider") ) - # BFS Queues for callers (inbound) and callees (outbound) - # Tree nodes structure: {"symbol": str, "children": [...], "rel_type": str, "filepath": str, "line": int, "permalink": str} - visited_edges = set() # (src_sym, tgt_sym, rel_type, direction) to avoid cycles + # Scoped repository determination: if repo is passed, scope BFS to it; + # otherwise if symbols_found, scope BFS to symbol's repo + bfs_repo = repo + if not bfs_repo and symbols_found: + bfs_repo = symbols_found[0]["repo"] + + # Target info + target_info = None + if symbols_found: + s0 = symbols_found[0] + s_repo = s0["repo"] + s_file = _clean_path(s0["filepath"]) + plink = get_permalink(s_repo, s_file, s0["start_line"], s0["end_line"]) + target_info = { + "symbol": s0["full_symbol"] or s0["name"], + "name": s0["name"], + "kind": s0["kind"], + "repo": s_repo, + "file": s_file, + "lines": [s0["start_line"], s0["end_line"]] if s0["start_line"] is not None and s0["end_line"] is not None else [], + "permalink": plink + } + else: + # Construct fallback target_info from ast_relationships if possible + with get_db_connection() as conn: + row = conn.execute( + "SELECT repo, source_filepath, line_number FROM ast_relationships WHERE source_symbol = ? " + ("AND repo = ? " if bfs_repo else "") + "LIMIT 1", + [target_sym, bfs_repo] if bfs_repo else [target_sym] + ).fetchone() + if not row: + row = conn.execute( + "SELECT repo, source_filepath, line_number FROM ast_relationships WHERE target_symbol = ? " + ("AND repo = ? " if bfs_repo else "") + "LIMIT 1", + [target_sym, bfs_repo] if bfs_repo else [target_sym] + ).fetchone() + + t_repo = row["repo"] if row else (bfs_repo or "") + t_file = _clean_path(row["source_filepath"]) if row else "" + t_line = row["line_number"] if row else 1 + plink = get_permalink(t_repo, t_file, t_line, t_line) if row else None + target_info = { + "symbol": target_sym, + "name": target_sym.split(".")[-1], + "kind": "function", + "repo": t_repo, + "file": t_file, + "lines": [t_line, t_line] if row else [], + "permalink": plink + } + + # Helper function to resolve cross-repo HTTP boundaries for a given symbol + def resolve_http_boundaries(sym_name: str, sym_repo: str): + """ + Returns (route_dict_or_None, external_callers_list, http_calls_list) + """ + route_meta = None + ext_callers = [] + http_calls = [] + + # 1. Inbound Route Match (api_routes -> api_client_calls) + # Check if sym is a route handler + matched_routes = [ + r for r in all_routes + if (r.get("repo") == sym_repo or not sym_repo) and + (r.get("handler_symbol") == sym_name or (r.get("handler_symbol") and (r["handler_symbol"] == sym_name.split(".")[-1] or sym_name.endswith(f".{r['handler_symbol']}")))) + ] + + if matched_routes: + # Attach route info + primary_route = matched_routes[0] + route_meta = { + "method": primary_route["http_method"].upper(), + "path": primary_route["path_pattern"] + } + + # Find external callers across all indexed repositories + for r_item in matched_routes: + r_method = r_item["http_method"] + r_pattern = r_item["path_pattern"] + for c in all_calls: + # Ignore internal callers from the same repo if looking for external callers, + # but spec says: "matching cross-repo HTTP callers (external_callers)" + if c["repo"] == sym_repo: + continue + if _methods_compatible(r_method, c.get("http_method")) and match_route_and_call(r_pattern, c.get("url_pattern", "")): + c_file = _clean_path(c["filepath"]) + caller_entry = { + "repo": c["repo"], + "symbol": c.get("caller_symbol") or "", + "file": c_file, + "line": c.get("line_number"), + "method": c.get("http_method", "").upper(), + "url": c.get("url_pattern", "") + } + if caller_entry not in ext_callers: + ext_callers.append(caller_entry) + + # 2. Outbound HTTP Call Match (api_client_calls -> api_routes) + # Check if sym makes outbound HTTP calls + matched_calls = [ + c for c in all_calls + if (c.get("repo") == sym_repo or not sym_repo) and + (c.get("caller_symbol") == sym_name or (c.get("caller_symbol") and (c["caller_symbol"] == sym_name.split(".")[-1] or sym_name.endswith(f".{c['caller_symbol']}")))) + ] + + if matched_calls: + # Group by outbound call site + for c in matched_calls: + c_method = c.get("http_method", "").upper() + c_url = c.get("url_pattern", "") + c_line = c.get("line_number") + + ext_handlers = [] + for r in all_routes: + if r["repo"] == sym_repo: + continue + if _methods_compatible(r.get("http_method"), c_method) and match_route_and_call(r.get("path_pattern", ""), c_url): + r_file = _clean_path(r["filepath"]) + handler_entry = { + "repo": r["repo"], + "symbol": r.get("handler_symbol") or "", + "file": r_file, + "line": r.get("start_line"), + "path": r.get("path_pattern") + } + if handler_entry not in ext_handlers: + ext_handlers.append(handler_entry) + + call_site_entry = { + "method": c_method, + "url": c_url, + "line": c_line, + "external_handlers": ext_handlers + } + http_calls.append(call_site_entry) + + return route_meta, ext_callers, http_calls + + # Attach HTTP boundaries to target_info if applicable + t_route, t_callers, t_http_calls = resolve_http_boundaries(target_sym, target_info.get("repo", "")) + if t_route: + target_info["route"] = t_route + if t_callers: + target_info["external_callers"] = t_callers + if t_http_calls: + target_info["http_calls"] = t_http_calls + + visited_edges = set() total_returned = 0 truncated = False - tree_nodes = [] - with get_db_connection() as conn: # Build inbound (callers) sub-tree callers_tree = [] if direction in ("callers", "both"): - # Queue items: (current_target_symbol, current_level, parent_node_list) queue = deque([(target_sym, 1, callers_tree)]) visited_nodes = {target_sym} - while queue and total_returned < limit: + while queue: curr_sym, curr_depth, parent_list = queue.popleft() if curr_depth > depth: continue @@ -100,9 +259,9 @@ def get_permalink(r_repo: str, r_filepath: str, r_line: int) -> Optional[str]: WHERE r.target_symbol = ? """ params = [curr_sym] - if repo: + if bfs_repo: query += " AND r.repo = ?" - params.append(repo) + params.append(bfs_repo) query += " ORDER BY r.line_number ASC" rows = conn.execute(query, params).fetchall() @@ -118,34 +277,45 @@ def get_permalink(r_repo: str, r_filepath: str, r_line: int) -> Optional[str]: visited_edges.add(edge_key) total_returned += 1 - plink = get_permalink(r["repo"], r["source_filepath"], r["line_number"]) + clean_fp = _clean_path(r["source_filepath"]) + plink = get_permalink(r["repo"], clean_fp, r["line_number"], r["line_number"]) + + r_route, r_ext_callers, r_http_calls = resolve_http_boundaries(r["source_symbol"], r["repo"]) + + rel_label = (r["relationship_type"] or "calls").lower() node = { "symbol": r["source_symbol"], - "rel_type": r["relationship_type"], - "filepath": r["source_filepath"], - "line": r["line_number"], - "start_line": r["start_line"], - "end_line": r["end_line"], + "rel": rel_label, "repo": r["repo"], - "permalink": plink, - "children": [] + "file": clean_fp, + "line": r["line_number"], + "permalink": plink } + if r_route: + node["route"] = r_route + if r_ext_callers: + node["external_callers"] = r_ext_callers + if r_http_calls: + node["http_calls"] = r_http_calls + + node["callers"] = [] parent_list.append(node) - # Continue BFS if within depth limit and symbol not in current path (prevent cycle) if curr_depth < depth and r["source_symbol"] not in visited_nodes: - next_visited = set(visited_nodes) - next_visited.add(r["source_symbol"]) - queue.append((r["source_symbol"], curr_depth + 1, node["children"])) + visited_nodes.add(r["source_symbol"]) + if total_returned < limit: + queue.append((r["source_symbol"], curr_depth + 1, node["callers"])) + else: + truncated = True # Build outbound (callees) sub-tree callees_tree = [] - if direction in ("callees", "both") and total_returned < limit: + if direction in ("callees", "both"): queue = deque([(target_sym, 1, callees_tree)]) visited_nodes = {target_sym} - while queue and total_returned < limit: + while queue: curr_sym, curr_depth, parent_list = queue.popleft() if curr_depth > depth: continue @@ -157,9 +327,9 @@ def get_permalink(r_repo: str, r_filepath: str, r_line: int) -> Optional[str]: WHERE r.source_symbol = ? """ params = [curr_sym] - if repo: + if bfs_repo: query += " AND r.repo = ?" - params.append(repo) + params.append(bfs_repo) query += " ORDER BY r.line_number ASC" rows = conn.execute(query, params).fetchall() @@ -175,67 +345,48 @@ def get_permalink(r_repo: str, r_filepath: str, r_line: int) -> Optional[str]: visited_edges.add(edge_key) total_returned += 1 - plink = get_permalink(r["repo"], r["source_filepath"], r["line_number"]) + clean_fp = _clean_path(r["source_filepath"]) + plink = get_permalink(r["repo"], clean_fp, r["line_number"], r["line_number"]) + + r_route, r_ext_callers, r_http_calls = resolve_http_boundaries(r["target_symbol"], r["repo"]) + + rel_label = (r["relationship_type"] or "calls").lower() node = { "symbol": r["target_symbol"], - "rel_type": r["relationship_type"], - "filepath": r["source_filepath"], - "line": r["line_number"], - "start_line": r["start_line"], - "end_line": r["end_line"], + "rel": rel_label, "repo": r["repo"], - "permalink": plink, - "children": [] + "file": clean_fp, + "line": r["line_number"], + "permalink": plink } + if r_route: + node["route"] = r_route + if r_ext_callers: + node["external_callers"] = r_ext_callers + if r_http_calls: + node["http_calls"] = r_http_calls + + node["callees"] = [] parent_list.append(node) if curr_depth < depth and r["target_symbol"] not in visited_nodes: - next_visited = set(visited_nodes) - next_visited.add(r["target_symbol"]) - queue.append((r["target_symbol"], curr_depth + 1, node["children"])) - - # Render ASCII / Markdown Tree - lines = [f"# Call Graph Trace: `{target_sym}` (Direction: {direction}, Max Depth: {depth})\n"] - if symbols_found: - s0 = symbols_found[0] - lines.append(f"**Target Symbol Definition:** `{s0['full_symbol'] or s0['name']}` ({s0['kind']}) in `[{s0['repo']}] {s0['filepath']}` (Lines {s0['start_line']}-{s0['end_line']})\n") - - if not callers_tree and not callees_tree: - lines.append("No relationship edges found within specified depth limit.") - return "\n".join(lines) - - def render_tree_node(node, prefix="", is_last=True): - connector = "└── " if is_last else "├── " - rel_info = f"[{node['rel_type']}]" - fp_info = f"`{node['filepath']}`:{node['line']}" - link_str = f" ([permalink]({node['permalink']}))" if node.get("permalink") else "" - - node_str = f"{prefix}{connector}{rel_info} **{node['symbol']}** in {fp_info}{link_str}" - child_prefix = prefix + (" " if is_last else "│ ") - - child_lines = [node_str] - for idx, child in enumerate(node["children"]): - last_child = (idx == len(node["children"]) - 1) - child_lines.extend(render_tree_node(child, prefix=child_prefix, is_last=last_child)) - return child_lines - - if direction in ("callers", "both") and callers_tree: - lines.append("### Callers (Inbound Dependencies / Invoked By)") - for idx, node in enumerate(callers_tree): - lines.extend(render_tree_node(node, is_last=(idx == len(callers_tree) - 1))) - lines.append("") - - if direction in ("callees", "both") and callees_tree: - lines.append("### Callees (Outbound Dependencies / Invokes)") - for idx, node in enumerate(callees_tree): - lines.extend(render_tree_node(node, is_last=(idx == len(callees_tree) - 1))) - lines.append("") - - if truncated or total_returned >= limit: - lines.append(f"\n*Note: Relationship output truncated at maximum limit of {limit} entries.*") - - return "\n".join(lines) + visited_nodes.add(r["target_symbol"]) + if total_returned < limit: + queue.append((r["target_symbol"], curr_depth + 1, node["callees"])) + else: + truncated = True + + payload = { + "target": target_info, + "direction": direction, + "depth": depth, + "truncated": truncated, + "callers": callers_tree, + "callees": callees_tree + } + + return json.dumps(payload, separators=(",", ":")) def _enrich_code_ast_metadata(results: List[VectorSearchResult]) -> None: diff --git a/app/services/topology/graph_builder.py b/app/services/topology/graph_builder.py index 22b6a3c..4f12c54 100644 --- a/app/services/topology/graph_builder.py +++ b/app/services/topology/graph_builder.py @@ -5,6 +5,7 @@ from app.services.database import get_db_connection from app.services.git_manager import format_git_permalink from app.services.topology.helpers import _clean_filepath, _get_permalink +from app.services.chunking import match_route_and_call logger = logging.getLogger("contextcortex.topology") import sys @@ -203,15 +204,32 @@ def add_edge(src: str, tgt: str, e_type: str, line: Optional[int] = None, label: add_edge(rt_id, s_id, "HANDLES", r["start_line"]) # ROUTES_TO Edges: Client Call -> Route + def _methods_match(m1: Optional[str], m2: Optional[str]) -> bool: + if not m1 or not m2: + return True + u1, u2 = m1.upper(), m2.upper() + return u1 == "ALL" or u2 == "ALL" or u1 == u2 + for c in call_rows: c_pattern = c["url_pattern"] - matched_route_ids = routes_by_pattern.get((c["repo"], c_pattern), []) - if not matched_route_ids: - for (rt_repo, rt_pat), rt_ids in routes_by_pattern.items(): - if rt_pat == c_pattern or c_pattern.rstrip("/") == rt_pat.rstrip("/"): - matched_route_ids = rt_ids - break - + matched_route_ids = [] + + # First try exact match in the same repo + matched_route_ids.extend(routes_by_pattern.get((c["repo"], c_pattern), [])) + + # Cross-repo or pattern matching via match_route_and_call + c_method = c["http_method"] if "http_method" in c.keys() else None + for r in route_rows: + rt_id = route_id_by_db_id.get(r["id"]) + if not rt_id or rt_id in matched_route_ids: + continue + # When repo is scoped to single repo, only link within repo unless __all__ + if repo != "__all__" and r["repo"] != c["repo"]: + continue + r_method = r["http_method"] if "http_method" in r.keys() else None + if _methods_match(r_method, c_method) and match_route_and_call(r["path_pattern"], c_pattern): + matched_route_ids.append(rt_id) + # Source of client call (caller symbol or file) clean_c_fp = _clean_filepath(c["filepath"]) caller_node_id = None diff --git a/tests/backend/test_trace_path.py b/tests/backend/test_trace_path.py index 7db38d6..f9c0a61 100644 --- a/tests/backend/test_trace_path.py +++ b/tests/backend/test_trace_path.py @@ -57,33 +57,49 @@ def setup_relationship_fixture(): conn.commit() +import json + def test_direct_and_mutual_recursion_termination(): # Direct recursion test res_direct = trace_symbol_path("fn_A", repo="test_graph", direction="both", depth=5) - assert "# Call Graph Trace: `fn_A`" in res_direct + data_direct = json.loads(res_direct) + assert data_direct["target"]["symbol"] == "fn_A" + assert data_direct["target"]["file"] == "a.py" # Mutual recursion test res_mutual = trace_symbol_path("fn_B", repo="test_graph", direction="both", depth=5) - assert "fn_C" in res_mutual - assert "fn_B" in res_mutual + data_mutual = json.loads(res_mutual) + assert data_mutual["target"]["symbol"] == "fn_B" + # Callees should have fn_C + callee_symbols = [c["symbol"] for c in data_mutual["callees"]] + assert "fn_C" in callee_symbols def test_depth_clamping_and_limit_truncation(): # Depth=1 returns immediate neighbor only (level1_fn), but not level2_fn res_d1 = trace_symbol_path("root_fn", repo="test_graph", direction="callees", depth=1) - assert "level1_fn" in res_d1 - assert "level2_fn" not in res_d1 + data_d1 = json.loads(res_d1) + assert len(data_d1["callees"]) == 1 + assert data_d1["callees"][0]["symbol"] == "level1_fn" + assert data_d1["callees"][0]["callees"] == [] # Depth=3 visits level1_fn, level2_fn, level3_fn res_d3 = trace_symbol_path("root_fn", repo="test_graph", direction="callees", depth=3) - assert "level1_fn" in res_d3 - assert "level2_fn" in res_d3 - assert "level3_fn" in res_d3 + data_d3 = json.loads(res_d3) + lvl1 = data_d3["callees"][0] + assert lvl1["symbol"] == "level1_fn" + assert len(lvl1["callees"]) == 1 + lvl2 = lvl1["callees"][0] + assert lvl2["symbol"] == "level2_fn" + assert len(lvl2["callees"]) == 1 + lvl3 = lvl2["callees"][0] + assert lvl3["symbol"] == "level3_fn" # Truncation limit test res_limit = trace_symbol_path("root_fn", repo="test_graph", direction="callees", depth=3, limit=1) - assert "level1_fn" in res_limit - assert "truncated at maximum limit" in res_limit + data_limit = json.loads(res_limit) + assert data_limit["truncated"] is True + assert len(data_limit["callees"]) == 1 def test_database_query_performance(): @@ -103,19 +119,119 @@ def test_database_query_performance(): res = trace_symbol_path("sym_0", repo="perf_repo", direction="both", depth=3, limit=25) duration_ms = (time.time() - start_time) * 1000.0 - assert "sym_1" in res - assert duration_ms < 100.0 # Standard test performance assertion (under 10ms for individual query execution) + data = json.loads(res) + assert data["target"]["symbol"] == "sym_0" + assert duration_ms < 100.0 @pytest.mark.asyncio async def test_trace_path_mcp_tool_execution(): res = await handle_trace_path("root_fn", repo="test_graph", direction="callees", depth=2) - assert "# Call Graph Trace: `root_fn`" in res - assert "level1_fn" in res + data = json.loads(res) + assert data["target"]["name"] == "root_fn" + assert len(data["callees"]) >= 1 + assert data["callees"][0]["symbol"] == "level1_fn" # Non-existent symbol error handling res_empty = await handle_trace_path("non_existent_symbol_12345", repo="test_graph") - assert "No symbols or relationships found" in res_empty + data_empty = json.loads(res_empty) + assert data_empty["found"] is False + assert data_empty["symbol"] == "non_existent_symbol_12345" + + # Empty symbol validation error handling + res_blank = await handle_trace_path("", repo="test_graph") + data_blank = json.loads(res_blank) + assert "error" in data_blank + + +def test_cross_repo_boundary_resolution(): + """ + Unit tests verifying cross-repo boundary resolution: + - Tracing a backend handler symbol in repo_b includes external_callers from repo_a via api_routes <-> api_client_calls matching. + - Tracing a client symbol in repo_a includes external_handlers in repo_b, while keeping AST BFS expansion scoped to repo_a. + """ + with get_db_connection() as conn: + # Repositories + conn.execute("INSERT OR REPLACE INTO git_repositories (name, url, commit_sha, provider) VALUES ('orders-api', 'https://github.com/org/orders-api.git', 'abc1234', 'github')") + conn.execute("INSERT OR REPLACE INTO git_repositories (name, url, commit_sha, provider) VALUES ('web-frontend', 'https://github.com/org/web-frontend.git', 'xyz9876', 'github')") + conn.execute("INSERT OR REPLACE INTO git_repositories (name, url, commit_sha, provider) VALUES ('tax-service', 'https://github.com/org/tax-service.git', 't1a2x3', 'github')") + + # AST symbols + conn.execute("INSERT INTO ast_symbols (repo, filepath, name, full_symbol, kind, start_line, end_line, signature, language) VALUES ('orders-api', 'orders-api://app/services/orders.py', 'create_order', 'OrderService.create_order', 'method', 45, 88, 'def create_order():', 'python')") + conn.execute("INSERT INTO ast_symbols (repo, filepath, name, full_symbol, kind, start_line, end_line, signature, language) VALUES ('orders-api', 'app/api/routes.py', 'checkout_endpoint', 'checkout_endpoint', 'function', 30, 40, 'def checkout_endpoint():', 'python')") + conn.execute("INSERT INTO ast_symbols (repo, filepath, name, full_symbol, kind, start_line, end_line, signature, language) VALUES ('orders-api', 'app/services/inventory.py', 'validate_inventory', 'validate_inventory', 'function', 14, 25, 'def validate_inventory():', 'python')") + conn.execute("INSERT INTO ast_symbols (repo, filepath, name, full_symbol, kind, start_line, end_line, signature, language) VALUES ('web-frontend', 'src/api/checkout.ts', 'submitCheckout', 'submitCheckout', 'function', 40, 60, 'export function submitCheckout()', 'typescript')") + conn.execute("INSERT INTO ast_symbols (repo, filepath, name, full_symbol, kind, start_line, end_line, signature, language) VALUES ('tax-service', 'app/routes/tax.py', 'get_tax_by_zip', 'get_tax_by_zip', 'function', 19, 35, 'def get_tax_by_zip():', 'python')") + + # Intra-repo relationships in orders-api: + # checkout_endpoint -> OrderService.create_order -> validate_inventory + conn.execute("INSERT INTO ast_relationships (repo, source_filepath, source_symbol, target_symbol, relationship_type, line_number) VALUES ('orders-api', 'app/api/routes.py', 'checkout_endpoint', 'OrderService.create_order', 'calls', 31)") + conn.execute("INSERT INTO ast_relationships (repo, source_filepath, source_symbol, target_symbol, relationship_type, line_number) VALUES ('orders-api', 'app/services/orders.py', 'OrderService.create_order', 'validate_inventory', 'calls', 14)") + + # Inbound route for checkout_endpoint + conn.execute("INSERT INTO api_routes (repo, filepath, framework, http_method, path_pattern, handler_symbol, start_line, end_line) VALUES ('orders-api', 'app/api/routes.py', 'FastAPI', 'POST', '/api/v1/orders', 'checkout_endpoint', 30, 40)") + + # Client call from web-frontend calling /api/v1/orders + conn.execute("INSERT INTO api_client_calls (repo, filepath, http_method, url_pattern, caller_symbol, line_number) VALUES ('web-frontend', 'web-frontend://src/api/checkout.ts', 'POST', '/api/v1/orders', 'submitCheckout', 44)") + + # Outbound HTTP call from validate_inventory calling tax-service GET /api/v1/tax/{zip} + conn.execute("INSERT INTO api_client_calls (repo, filepath, http_method, url_pattern, caller_symbol, line_number) VALUES ('orders-api', 'app/services/inventory.py', 'GET', '/api/v1/tax/{zip}', 'validate_inventory', 27)") + + # Route in tax-service + conn.execute("INSERT INTO api_routes (repo, filepath, framework, http_method, path_pattern, handler_symbol, start_line, end_line) VALUES ('tax-service', 'app/routes/tax.py', 'FastAPI', 'GET', '/api/v1/tax/:zip', 'get_tax_by_zip', 19, 35)") + + conn.commit() + + # 1. Trace OrderService.create_order in orders-api + res_raw = trace_symbol_path("OrderService.create_order", repo="orders-api", direction="both", depth=2) + data = json.loads(res_raw) + + assert data["target"]["symbol"] == "OrderService.create_order" + assert data["target"]["file"] == "app/services/orders.py" + assert data["target"]["repo"] == "orders-api" + assert "://" not in data["target"]["file"] + + # Inbound callers check: checkout_endpoint should have route and external_callers from web-frontend + assert len(data["callers"]) == 1 + caller_node = data["callers"][0] + assert caller_node["symbol"] == "checkout_endpoint" + assert caller_node["file"] == "app/api/routes.py" + assert caller_node["route"] == {"method": "POST", "path": "/api/v1/orders"} + assert len(caller_node["external_callers"]) == 1 + ext_caller = caller_node["external_callers"][0] + assert ext_caller["repo"] == "web-frontend" + assert ext_caller["symbol"] == "submitCheckout" + assert ext_caller["file"] == "src/api/checkout.ts" + assert "://" not in ext_caller["file"] + assert ext_caller["line"] == 44 + + # Outbound callees check: validate_inventory should have http_calls with external_handlers in tax-service + assert len(data["callees"]) == 1 + callee_node = data["callees"][0] + assert callee_node["symbol"] == "validate_inventory" + assert len(callee_node["http_calls"]) == 1 + http_call = callee_node["http_calls"][0] + assert http_call["method"] == "GET" + assert http_call["url"] == "/api/v1/tax/{zip}" + assert len(http_call["external_handlers"]) == 1 + ext_handler = http_call["external_handlers"][0] + assert ext_handler["repo"] == "tax-service" + assert ext_handler["symbol"] == "get_tax_by_zip" + assert ext_handler["file"] == "app/routes/tax.py" + assert "://" not in ext_handler["file"] + assert ext_handler["line"] == 19 + + # 2. Trace frontend symbol submitCheckout: target itself should have http_calls with external_handlers + res_fe = trace_symbol_path("submitCheckout", repo="web-frontend", direction="callees", depth=2) + data_fe = json.loads(res_fe) + assert data_fe["target"]["symbol"] == "submitCheckout" + assert data_fe["target"]["repo"] == "web-frontend" + assert len(data_fe["target"]["http_calls"]) == 1 + fe_call = data_fe["target"]["http_calls"][0] + assert fe_call["method"] == "POST" + assert any(h["repo"] == "orders-api" and h["symbol"] == "checkout_endpoint" for h in fe_call["external_handlers"]) + # AST BFS stays scoped to web-frontend + assert data_fe["callees"] == [] from mcp.server.fastmcp import FastMCP @@ -169,4 +285,13 @@ async def test_trace_path_over_http_transport(): headers["mcp-session-id"] = session_id resp = await client.post("/mcp", json=payload, headers=headers) assert resp.status_code == 200 - assert "trace_path" in resp.text or "Call Graph Trace" in resp.text + # Response may be SSE stream or direct json + if "data:" in resp.text: + data_line = [line for line in resp.text.splitlines() if line.startswith("data:")][0] + resp_json = json.loads(data_line[5:].strip()) + else: + resp_json = resp.json() + tool_content = resp_json["result"]["content"][0]["text"] + parsed = json.loads(tool_content) + assert parsed["target"]["name"] == "root_fn" + assert parsed["callees"][0]["symbol"] == "level1_fn"