diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 207bc63..fb1f5a0 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:** **1207 Automated Tests** (843 Pytest Backend + 314 Vitest Frontend + 50 Playwright E2E). +**Test Verification Baseline:** **1216 Automated Tests** (852 Pytest Backend + 314 Vitest Frontend + 50 Playwright E2E). --- @@ -1178,6 +1178,17 @@ and leaves the prior indexed state intact without data loss._ - `test_summarizer_client_sends_extra_headers` - _Verifies that SummarizerService builds its real OpenAI client from SQLite settings and refreshes on rotation._ - `test_embeddings_api_client_sends_extra_headers` - _Verifies that API embedding initialization configures OpenAI with persisted settings and extra headers._ +#### `tests/test_local_source_relative_paths.py` (9 tests) +- `test_relative_maps_stored_paths` +- `test_nested_roots_use_the_longest_root_and_file_roots_use_the_file_name` +- `test_clean_stored_path_keeps_the_git_form` +- `test_find_symbol_returns_relative_paths` +- `test_get_file_outline_returns_the_relative_path` +- `test_find_routes_and_api_callers_return_relative_paths` +- `test_trace_path_returns_relative_paths` +- `test_diagnose_issue_parts_return_relative_paths` +- `test_resolved_stack_frames_return_relative_paths` + #### `tests/test_local_storage_indexing.py` (4 tests) - `test_incremental_indexing_on_save` - `test_incremental_indexing_code_file` diff --git a/app/mcp/handlers/route_handlers.py b/app/mcp/handlers/route_handlers.py index 2088549..109f427 100644 --- a/app/mcp/handlers/route_handlers.py +++ b/app/mcp/handlers/route_handlers.py @@ -7,6 +7,7 @@ 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 +from app.services.source_paths import LocalSourceRoots logger = logging.getLogger("contextcortex") @@ -64,11 +65,12 @@ async def handle_find_routes( sql += " ORDER BY r.repo ASC, r.path_pattern ASC, r.http_method ASC, r.start_line ASC" rows = conn.execute(sql, params).fetchall() + local_roots = LocalSourceRoots.load(conn) all_routes = [] seen_routes = set() for r in rows: - rel_path = (r["filepath"].split("://", 1)[1] if "://" in r["filepath"] else r["filepath"]).replace("\\", "/") + rel_path = local_roots.relative(r["filepath"], r["repo"]) if _is_test_route_file(rel_path): continue dedup_key = (r["repo"], r["http_method"], r["path_pattern"], rel_path, r["start_line"]) @@ -146,6 +148,7 @@ async def handle_find_api_callers( sql += " ORDER BY c.repo ASC, c.filepath ASC, c.line_number ASC" rows = conn.execute(sql, params).fetchall() + local_roots = LocalSourceRoots.load(conn) target_method_clean = method.strip().upper() if method and method.strip() else None all_callers = [] @@ -159,7 +162,7 @@ async def handle_find_api_callers( if not match_route_and_call(target_path, c["url_pattern"]): continue - rel_path = (c["filepath"].split("://", 1)[1] if "://" in c["filepath"] else c["filepath"]).replace("\\", "/") + rel_path = local_roots.relative(c["filepath"], c["repo"]) dedup_key = (c["repo"], rel_path, c["http_method"], c["url_pattern"], c["caller_symbol"], c["line_number"]) if dedup_key in seen_callers: continue diff --git a/app/mcp/handlers/symbol_handlers.py b/app/mcp/handlers/symbol_handlers.py index 4aaf671..4575d70 100644 --- a/app/mcp/handlers/symbol_handlers.py +++ b/app/mcp/handlers/symbol_handlers.py @@ -6,6 +6,7 @@ from app.services.database import get_db_connection from app.services.search import trace_symbol_path +from app.services.source_paths import LocalSourceRoots logger = logging.getLogger("contextcortex") @@ -107,11 +108,12 @@ async def handle_find_symbol( query += " ORDER BY repo ASC, filepath ASC, start_line ASC, name ASC" rows = conn.execute(query, params).fetchall() + local_roots = LocalSourceRoots.load(conn) all_symbols = [] seen_syms = set() for r in rows: - rel_file = _strip_repo_prefix(r["filepath"]) + rel_file = local_roots.relative(r["filepath"], r["repo"]) sym_full = r["full_symbol"] or r["name"] dedup_key = (r["repo"], rel_file, r["name"], sym_full, r["kind"], r["start_line"], r["end_line"]) if dedup_key in seen_syms: @@ -266,6 +268,11 @@ async def handle_get_file_outline( candidates: List[str] = [] matched_file = _strip_repo_prefix(fp) with _get_tools_attr("get_db_connection", get_db_connection)() as conn: + local_roots = LocalSourceRoots.load(conn) + + def _rel(row) -> str: + return local_roots.relative(row["filepath"], row["repo"]) + def _query_outline_rows(target_clean: str): if repo: return conn.execute( @@ -298,11 +305,11 @@ def _query_outline_rows(target_clean: str): rows = _query_outline_rows(clean_fp) if rows: distinct_files = sorted({ - _strip_repo_prefix(r["filepath"]) for r in rows if r["filepath"] + _rel(r) for r in rows if r["filepath"] }) if len(distinct_files) > 1: chosen_file = clean_fp if clean_fp in distinct_files else distinct_files[0] - rows = [r for r in rows if _strip_repo_prefix(r["filepath"]) == chosen_file] + rows = [r for r in rows if _rel(r) == chosen_file] matched_file = chosen_file candidates = distinct_files @@ -313,14 +320,14 @@ def _query_outline_rows(target_clean: str): cand_rows = _query_outline_rows(suffix) if cand_rows: distinct_files = sorted({ - _strip_repo_prefix(r["filepath"]) for r in cand_rows if r["filepath"] + _rel(r) for r in cand_rows if r["filepath"] }) if len(distinct_files) == 1: rows = cand_rows matched_file = distinct_files[0] break if suffix in distinct_files: - rows = [r for r in cand_rows if _strip_repo_prefix(r["filepath"]) == suffix] + rows = [r for r in cand_rows if _rel(r) == suffix] matched_file = suffix candidates = distinct_files break @@ -329,7 +336,7 @@ def _query_outline_rows(target_clean: str): resolved_repo = repo or (rows[0]["repo"] if rows and "repo" in rows[0].keys() else None) if rows and rows[0]["filepath"]: - matched_file = _strip_repo_prefix(rows[0]["filepath"]) + matched_file = _rel(rows[0]) all_symbols = [] seen_syms = set() diff --git a/app/services/diagnostics.py b/app/services/diagnostics.py index 18bc2a2..f53b7b7 100644 --- a/app/services/diagnostics.py +++ b/app/services/diagnostics.py @@ -8,6 +8,7 @@ from app.services.file_reader import get_file_reader_service from app.services.git_manager import format_git_permalink from app.services.search import execute_hybrid_search +from app.services.source_paths import LocalSourceRoots from app.services.stack_trace_parser import ( RE_COBOL_ABEND_FRAME, RE_COBOL_DIAG_FRAME, RE_COBOL_PARAGRAPH_FRAME, RE_CSHARP_FRAME, RE_EXCEPTION_NAME, RE_GENERIC_FRAME, RE_HTTP_ROUTE, @@ -26,6 +27,11 @@ MAX_MATCHED_SYMBOLS = 20 +def _diag_file(roots: LocalSourceRoots, filepath: Optional[str]) -> str: + """Output path for a stored filepath: relative to its local-source root, else the cleaned diagnostic path.""" + return _clean_diag_path(roots.strip_root(filepath) or filepath) + + def _cobol_alt_filenames(filename: str) -> List[str]: lower = filename.lower() stem, dot, ext = lower.rpartition(".") @@ -61,10 +67,11 @@ def _get_callers_and_callees( c_sql += " AND repo = ?" c_params.append(effective_repo) c_rows = conn.execute(c_sql + " LIMIT 80", c_params).fetchall() + roots = LocalSourceRoots.load(conn) scored_callers, seen_callers = [], set() for r in c_rows: - clean_fp = _clean_diag_path(r["source_filepath"]) + clean_fp = _diag_file(roots, r["source_filepath"]) dedup_k = (r["repo"], r["source_symbol"], clean_fp, r["line_number"]) if dedup_k in seen_callers: continue @@ -100,7 +107,7 @@ def _get_callers_and_callees( callees, seen_callees = [], set() for r in ce_rows: - clean_fp = _clean_diag_path(r["source_filepath"]) + clean_fp = _diag_file(roots, r["source_filepath"]) dedup_k = (r["target_symbol"], r["line_number"]) if dedup_k in seen_callees: continue @@ -195,11 +202,12 @@ def resolve_stack_frames( r["name"]: dict(r) for r in conn.execute("SELECT name, url, commit_sha, provider FROM git_repositories").fetchall() } + roots = LocalSourceRoots.load(conn) for frame in frames[:MAX_RESOLVED_FRAMES]: raw_path, line_no = frame["path"], int(frame["line"]) matched_file = _find_matching_indexed_file(conn, repo, raw_path) file_path = matched_file["filepath"] if matched_file else raw_path - clean_file_path = _clean_diag_path(file_path) + clean_file_path = _diag_file(roots, file_path) target_repo = matched_file["repo"] if matched_file else (repo or "unknown") enclosing = _find_symbol_enclosing(conn, target_repo, file_path, line_no) @@ -270,6 +278,7 @@ def match_routes_and_callers( + (" WHERE repo = ?" if repo else ""), ([repo] if repo else []), ).fetchall() + roots = LocalSourceRoots.load(conn) for method, path in extracted_routes: norm_target = normalize_path_pattern(path) @@ -282,7 +291,7 @@ def match_routes_and_callers( continue seen.add(route_key) raw_ext_callers = [ - {"repo": c["repo"], "symbol": c["caller_symbol"], "file": _clean_diag_path(c["filepath"]), "line": c["line_number"]} + {"repo": c["repo"], "symbol": c["caller_symbol"], "file": _diag_file(roots, c["filepath"]), "line": c["line_number"]} for c in all_calls if match_route_and_call(r["path_pattern"], c["url_pattern"]) ] @@ -292,7 +301,7 @@ def match_routes_and_callers( "path": r["path_pattern"], "handler": { "repo": r["repo"], "symbol": r["handler_symbol"], - "file": _clean_diag_path(r["filepath"]), "line": r["start_line"], + "file": _diag_file(roots, r["filepath"]), "line": r["start_line"], }, "external_callers": raw_ext_callers[:10], }) @@ -315,6 +324,7 @@ def match_symbols_and_exceptions( return matched with get_db_connection() as conn: + roots = LocalSourceRoots.load(conn) for term in terms: short_term = term.split(".")[-1] if "." in term else term sql = ( @@ -334,7 +344,7 @@ def match_symbols_and_exceptions( ), ) for r in rows_sorted[:5]: - clean_fp = _clean_diag_path(r["filepath"]) + clean_fp = _diag_file(roots, r["filepath"]) if _is_test_path(clean_fp): continue key = (r["repo"], r["name"], clean_fp, r["start_line"]) diff --git a/app/services/graph_traversal.py b/app/services/graph_traversal.py index 84e827e..e35c2fe 100644 --- a/app/services/graph_traversal.py +++ b/app/services/graph_traversal.py @@ -6,6 +6,7 @@ from app.services.chunking import match_route_and_call from app.services.database import get_db_connection from app.services.git_manager import format_git_permalink +from app.services.source_paths import LocalSourceRoots _DIRECTION_ALIASES = { "inbound": "callers", @@ -120,10 +121,11 @@ def trace_symbol_path( git_repos = {r["name"]: dict(r) for r in conn.execute("SELECT name, url, commit_sha, provider FROM git_repositories").fetchall()} 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()] + _rel_path = LocalSourceRoots.load(conn).relative 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) - return _fmt_permalink(g_info["url"], g_info["commit_sha"], _clean_path(r_filepath), start_l, end_l, provider=g_info.get("provider")) if g_info else None + return _fmt_permalink(g_info["url"], g_info["commit_sha"], _rel_path(r_filepath), start_l, end_l, provider=g_info.get("provider")) if g_info else None bfs_repo = repo or (symbols_found[0]["repo"] if symbols_found else None) bfs_clause = " WHERE repo = ?" if bfs_repo else "" @@ -151,7 +153,7 @@ def get_permalink(r_repo: str, r_filepath: str, start_l: Optional[int], end_l: O class_signatures: Dict[str, str] = {} for s in repo_symbols: - s["_clean_fp"] = _clean_path(s.get("filepath")) + s["_clean_fp"] = _rel_path(s.get("filepath")) full_s, name_s = s.get("full_symbol"), s.get("name") if full_s: syms_by_full.setdefault(full_s, []).append(s) @@ -255,7 +257,7 @@ def _resolve_enclosing_symbol(r_repo: str, r_filepath: str, line_num: Optional[i if source_symbol_id and source_symbol_id in sym_by_id: s_obj = sym_by_id[source_symbol_id] return (s_obj.get("full_symbol") or s_obj.get("name") or fallback_sym), s_obj - file_syms = syms_by_file.get((r_repo, _clean_path(r_filepath)), []) + file_syms = syms_by_file.get((r_repo, _rel_path(r_filepath)), []) if line_num is not None and file_syms: enclosing = [ s for s in file_syms @@ -283,7 +285,7 @@ def resolve_http_boundaries(sym_name: str, sym_repo: str): for r_item in matched_routes: for c in all_calls: if c["repo"] != sym_repo and _methods_compatible(r_item["http_method"], c.get("http_method")) and match_route_and_call(r_item["path_pattern"], c.get("url_pattern", "")): - entry = {"repo": c["repo"], "symbol": c.get("caller_symbol") or "", "file": _clean_path(c["filepath"]), "line": c.get("line_number"), "method": c.get("http_method", "").upper(), "url": c.get("url_pattern", "")} + entry = {"repo": c["repo"], "symbol": c.get("caller_symbol") or "", "file": _rel_path(c["filepath"]), "line": c.get("line_number"), "method": c.get("http_method", "").upper(), "url": c.get("url_pattern", "")} if entry not in ext_callers: ext_callers.append(entry) @@ -297,7 +299,7 @@ def resolve_http_boundaries(sym_name: str, sym_repo: str): ext_handlers = [] for r in all_routes: if r["repo"] != sym_repo and _methods_compatible(r.get("http_method"), c_method) and match_route_and_call(r.get("path_pattern", ""), c_url): - h_entry = {"repo": r["repo"], "symbol": r.get("handler_symbol") or "", "file": _clean_path(r["filepath"]), "line": r.get("start_line"), "path": r.get("path_pattern")} + h_entry = {"repo": r["repo"], "symbol": r.get("handler_symbol") or "", "file": _rel_path(r["filepath"]), "line": r.get("start_line"), "path": r.get("path_pattern")} if h_entry not in ext_handlers: ext_handlers.append(h_entry) http_calls.append({"method": c_method, "url": c_url, "line": c.get("line_number"), "external_handlers": ext_handlers}) @@ -305,7 +307,7 @@ def resolve_http_boundaries(sym_name: str, sym_repo: str): if symbols_found: s0 = symbols_found[0] - s_file = _clean_path(s0["filepath"]) + s_file = _rel_path(s0["filepath"]) target_info = { "symbol": s0["full_symbol"] or s0["name"], "name": s0["name"], "kind": s0["kind"], "repo": s0["repo"], "file": s_file, @@ -320,7 +322,7 @@ def resolve_http_boundaries(sym_name: str, sym_repo: str): if not row: row = conn.execute(f"SELECT repo, source_filepath, line_number FROM ast_relationships WHERE target_symbol = ? {r_filter}LIMIT 1", r_args).fetchone() t_repo = row["repo"] if row else (bfs_repo or "") - t_file = _clean_path(row["source_filepath"]) if row else "" + t_file = _rel_path(row["source_filepath"]) if row else "" t_line = row["line_number"] if row else 1 target_info = { "symbol": target_sym, "name": target_sym.split(".")[-1], "kind": "function", @@ -337,7 +339,7 @@ def resolve_http_boundaries(sym_name: str, sym_repo: str): target_info["http_calls"] = t_http_calls def _make_edge_node(display_sym: str, row_obj: object, child_key: str) -> dict: - clean_fp = _clean_path(row_obj["source_filepath"]) + clean_fp = _rel_path(row_obj["source_filepath"]) r_route, r_ext_callers, r_http_calls = resolve_http_boundaries(display_sym, row_obj["repo"]) node = { "symbol": display_sym, diff --git a/app/services/source_paths.py b/app/services/source_paths.py new file mode 100644 index 0000000..3e41c10 --- /dev/null +++ b/app/services/source_paths.py @@ -0,0 +1,83 @@ +"""Maps stored file paths to the repository-relative form that tools return. + +Git sources store `ast_symbols.filepath` (and the other `filepath` columns) as `repo://relative/path`. +Local sources (rows in `indexed_paths`) store the absolute disk path. Tool output must use the path +relative to the source root for both, because `read_file` and `grep_code` take that form. +""" +import logging +import os +import re +from typing import Iterable, List, Optional, Tuple + +logger = logging.getLogger("contextcortex.source_paths") + +_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:(?:/|$)") + + +def clean_stored_path(filepath: Optional[str]) -> str: + """Removes a `repo://` prefix and converts backslashes to forward slashes.""" + if not filepath: + return "" + norm = filepath.replace("\\", "/") + return norm.split("://", 1)[1] if "://" in norm else norm + + +def _normalize_root(path: Optional[str]) -> str: + if not isinstance(path, str) or not path.strip(): + return "" + raw = path.strip() + norm = raw.replace("\\", "/") + if not (norm.startswith("/") or _WINDOWS_DRIVE_RE.match(norm)): + norm = os.path.abspath(raw).replace("\\", "/") + return norm.rstrip("/") + + +class LocalSourceRoots: + """Root folders of the local sources (`indexed_paths`), used to make stored absolute paths relative.""" + + def __init__(self, entries: Iterable[Tuple[Optional[str], Optional[str]]] = ()): + roots: List[Tuple[Optional[str], str]] = [] + for repo, path in entries: + root = _normalize_root(path) + if root: + roots.append((repo, root)) + # The longest root wins when local sources are nested. + roots.sort(key=lambda item: len(item[1]), reverse=True) + self._roots = roots + + @classmethod + def load(cls, conn) -> "LocalSourceRoots": + """Reads all `indexed_paths` roots with an open database connection. Returns no roots on error.""" + try: + rows = conn.execute("SELECT repo, path FROM indexed_paths").fetchall() + entries = [(row["repo"], row["path"]) for row in rows or []] + except Exception as e: + logger.debug(f"Could not read indexed_paths roots: {e}") + return cls() + return cls(entries) + + def strip_root(self, filepath: Optional[str], repo: Optional[str] = None) -> Optional[str]: + """Returns the path relative to the local-source root that contains it, or None if no root contains it.""" + if not filepath or not self._roots: + return None + norm = filepath.replace("\\", "/") + if "://" in norm: + return None + norm_lower = norm.lower() + preferred = [r for r in self._roots if repo and r[0] == repo] + for _, root in preferred + self._roots: + root_lower = root.lower() + if norm_lower == root_lower: + # The source is one file: its root folder is the parent folder. + return norm.rsplit("/", 1)[-1] + if norm_lower.startswith(root_lower + "/"): + return norm[len(root) + 1:].lstrip("/") + return None + + def relative(self, filepath: Optional[str], repo: Optional[str] = None) -> str: + """Returns the repository-relative form of a stored filepath (git `repo://` or local absolute path).""" + stripped = self.strip_root(filepath, repo) + return stripped if stripped is not None else clean_stored_path(filepath) + + +__all__ = ["LocalSourceRoots", "clean_stored_path"] diff --git a/docs/mcp-tools-and-resources.md b/docs/mcp-tools-and-resources.md index 29a7ba4..a611b61 100644 --- a/docs/mcp-tools-and-resources.md +++ b/docs/mcp-tools-and-resources.md @@ -11,6 +11,7 @@ ContextCortex extracts AST symbols, call relationships, HTTP routes, and semanti - **Primary AST & Code Languages**: Python, TypeScript/JS/TSX, C#, COBOL, Go, Rust, Java, and C/C++. - **Documentation & Configuration**: Markdown (`.md`), Architecture Decision Records (MADR / Nygard), PDFs (with optional Vision OCR), SQL, YAML, TOML, and JSON. - **Ingested Source Types**: Remote Git repositories (`git_repositories`), monitored local directories (`indexed_paths`), and managed local file storage (`local_storage`). +- **File Paths in Tool Output**: `find_symbol`, `get_file_outline`, `trace_path`, `find_routes`, `find_api_callers`, `search_code`, and `diagnose_issue` give each file as a path relative to the root of its source (for example `server/Controllers/ToolsController.cs`), for Git repositories and for monitored local directories. This is the same form that `read_file` and `grep_code` accept with the `repo` argument. The index keeps absolute disk paths for local directories; the tools remove the `indexed_paths` root when they make the output, so no re-index is necessary. --- diff --git a/tests/test_local_source_relative_paths.py b/tests/test_local_source_relative_paths.py new file mode 100644 index 0000000..723bb3d --- /dev/null +++ b/tests/test_local_source_relative_paths.py @@ -0,0 +1,171 @@ +"""Tools return repository-relative paths for local sources (`indexed_paths`), the same form as for git repositories.""" + +import asyncio +import json + +import pytest + +from app.mcp.handlers.route_handlers import handle_find_api_callers, handle_find_routes +from app.mcp.handlers.symbol_handlers import handle_find_symbol, handle_get_file_outline +from app.services.database import get_db_connection +from app.services.database.engine import get_db_engine, init_db +from app.services.diagnostics import ( + _get_callers_and_callees, match_routes_and_callers, match_symbols_and_exceptions, resolve_stack_frames, +) +from app.services.graph_traversal import trace_symbol_path +from app.services.source_paths import LocalSourceRoots, clean_stored_path + +LOCAL_REPO = "hub-prefix" +LOCAL_ROOT = r"C:\Development\TEMP\claude\debug-eval\hub-prefix" +CONTROLLER = "server/Controllers/ToolsController.cs" +SERVICE = "server/Services/ToolService.cs" +GIT_REPO = "web-client" +CLIENT = "src/api/tools.ts" + + +def _local(rel: str) -> str: + return LOCAL_ROOT + "\\" + rel.replace("/", "\\") + + +@pytest.mark.parametrize("filepath, repo, expected", [ + (_local(CONTROLLER), LOCAL_REPO, CONTROLLER), + (_local(CONTROLLER).replace("\\", "/"), None, CONTROLLER), + (_local(CONTROLLER).lower(), None, CONTROLLER.lower()), + (f"{GIT_REPO}://{CLIENT}", GIT_REPO, CLIENT), + (r"D:\Other\server\X.cs", None, "D:/Other/server/X.cs"), + (LOCAL_ROOT + "-old\\server\\X.cs", None, "C:/Development/TEMP/claude/debug-eval/hub-prefix-old/server/X.cs"), + ("", None, ""), + (None, None, ""), +]) +def test_relative_maps_stored_paths(filepath, repo, expected): + roots = LocalSourceRoots([(LOCAL_REPO, LOCAL_ROOT)]) + assert roots.relative(filepath, repo) == expected + + +def test_nested_roots_use_the_longest_root_and_file_roots_use_the_file_name(): + roots = LocalSourceRoots([ + ("outer", "/srv/code"), + ("inner", "/srv/code/inner/"), + ("single", r"C:\notes\todo.md"), + ]) + assert roots.relative("/srv/code/inner/a/b.py") == "a/b.py" + assert roots.relative("/srv/code/a/b.py") == "a/b.py" + assert roots.relative(r"C:\notes\todo.md", "single") == "todo.md" + assert roots.strip_root("/srv/other/b.py") is None + assert roots.strip_root("outer://a/b.py") is None + + +def test_clean_stored_path_keeps_the_git_form(): + assert clean_stored_path("repo://a\\b.py") == "a/b.py" + assert clean_stored_path(r"C:\x\y.py") == "C:/x/y.py" + + +@pytest.fixture +def local_source_db(tmp_path, monkeypatch): + db_file = tmp_path / "local_paths.db" + db_url = f"sqlite:///{db_file}" + monkeypatch.setenv("DATABASE_URL", db_url) + monkeypatch.setattr("app.services.database.connection.CACHE_DB_PATH", str(db_file)) + monkeypatch.setattr("app.services.database.CACHE_DB_PATH", str(db_file), raising=False) + init_db(engine=get_db_engine(db_url, reset=True)) + + with get_db_connection() as conn: + conn.execute( + "INSERT INTO indexed_paths (path, type, recursive, category, repo) VALUES (?, 'directory', 1, 'code', ?)", + (LOCAL_ROOT, LOCAL_REPO), + ) + + def add_symbol(repo, fp, name, full, kind, start, end): + return conn.execute( + "INSERT INTO ast_symbols (repo, filepath, name, full_symbol, kind, start_line, end_line, signature, language) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'c_sharp')", + (repo, fp, name, full, kind, start, end, f"{name}()"), + ).lastrowid + + add_symbol(LOCAL_REPO, _local(CONTROLLER), "ToolsController", "ToolsController", "class_declaration", 1, 60) + get_id = add_symbol(LOCAL_REPO, _local(CONTROLLER), "GetTool", "ToolsController.GetTool", "method_declaration", 10, 20) + add_symbol(LOCAL_REPO, _local(SERVICE), "ToolService", "ToolService", "class_declaration", 1, 40) + add_symbol(LOCAL_REPO, _local(SERVICE), "LoadTool", "ToolService.LoadTool", "method_declaration", 5, 15) + add_symbol(GIT_REPO, f"{GIT_REPO}://{CLIENT}", "fetchTool", "fetchTool", "function_declaration", 3, 9) + + conn.execute( + "INSERT INTO ast_relationships (repo, source_symbol_id, source_filepath, source_symbol, target_symbol, relationship_type, line_number) " + "VALUES (?, ?, ?, 'ToolsController.GetTool', 'ToolService.LoadTool', 'CALLS', 12)", + (LOCAL_REPO, get_id, _local(CONTROLLER)), + ) + conn.execute( + "INSERT INTO api_routes (repo, filepath, framework, http_method, path_pattern, handler_symbol, start_line, end_line) " + "VALUES (?, ?, 'aspnet', 'GET', '/api/tools/{id}', 'ToolsController.GetTool', 10, 20)", + (LOCAL_REPO, _local(CONTROLLER)), + ) + conn.execute( + "INSERT INTO api_client_calls (repo, filepath, http_method, url_pattern, caller_symbol, line_number) " + "VALUES (?, ?, 'GET', '/api/tools/{id}', 'fetchTool', 5)", + (GIT_REPO, f"{GIT_REPO}://{CLIENT}"), + ) + conn.execute( + "INSERT INTO api_client_calls (repo, filepath, http_method, url_pattern, caller_symbol, line_number) " + "VALUES (?, ?, 'GET', '/api/tools/{id}', 'ToolService.LoadTool', 7)", + (LOCAL_REPO, _local(SERVICE)), + ) + + +def _run(coro): + return json.loads(asyncio.run(coro)) + + +def test_find_symbol_returns_relative_paths(local_source_db): + result = _run(handle_find_symbol(name="GetTool")) + assert [s["file"] for s in result["symbols"]] == [CONTROLLER] + git_result = _run(handle_find_symbol(name="fetchTool")) + assert [s["file"] for s in git_result["symbols"]] == [CLIENT] + + +def test_get_file_outline_returns_the_relative_path(local_source_db): + result = _run(handle_get_file_outline(filepath=CONTROLLER, repo=LOCAL_REPO)) + assert result["file"] == CONTROLLER + assert {s["name"] for s in result["symbols"]} == {"ToolsController", "GetTool"} + + +def test_find_routes_and_api_callers_return_relative_paths(local_source_db): + routes = _run(handle_find_routes(query="/api/tools")) + assert [r["file"] for r in routes["routes"]] == [CONTROLLER] + callers = _run(handle_find_api_callers(path="/api/tools/{id}")) + assert sorted(c["file"] for c in callers["callers"]) == sorted([CLIENT, SERVICE]) + + +def test_trace_path_returns_relative_paths(local_source_db): + result = json.loads(trace_symbol_path("ToolService.LoadTool", repo=LOCAL_REPO, direction="callers")) + assert result["target"]["file"] == SERVICE + assert [c["file"] for c in result["callers"]] == [CONTROLLER] + assert result["callers"][0]["symbol"] == "ToolsController.GetTool" + handler = json.loads(trace_symbol_path("fetchTool", repo=GIT_REPO, direction="callers")) + assert handler["target"]["file"] == CLIENT + assert [h["file"] for call in handler["target"]["http_calls"] for h in call["external_handlers"]] == [CONTROLLER] + + +def test_diagnose_issue_parts_return_relative_paths(local_source_db): + routes = match_routes_and_callers([("GET", "/api/tools/42")]) + assert routes[0]["handler"]["file"] == CONTROLLER + assert sorted(c["file"] for c in routes[0]["external_callers"]) == sorted([CLIENT, SERVICE]) + + symbols = match_symbols_and_exceptions([], [], candidate_symbols=["ToolsController.GetTool"]) + assert [s["file"] for s in symbols] == [CONTROLLER] + + with get_db_connection() as conn: + callers, callees = _get_callers_and_callees( + conn, LOCAL_REPO, "ToolsController.GetTool", enclosing_filepath=CONTROLLER, + enclosing_start_line=10, enclosing_end_line=20, + ) + service_callers, _ = _get_callers_and_callees(conn, LOCAL_REPO, "ToolService.LoadTool") + assert [c["file"] for c in callees] == [CONTROLLER] + assert [c["file"] for c in service_callers] == [CONTROLLER] + assert callers == [] + + +def test_resolved_stack_frames_return_relative_paths(local_source_db): + frames = [{"path": _local(CONTROLLER), "line": 15, "function": "GetTool"}] + resolved = resolve_stack_frames(frames, repo=LOCAL_REPO) + assert resolved[0]["file"] == CONTROLLER + assert resolved[0]["symbol"] == "ToolsController.GetTool" + assert [c["file"] for c in resolved[0]["callees"]] == [CONTROLLER]