diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index abd58d6..c00e085 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:** **925 Automated Tests** (611 Pytest Backend + 268 Vitest Frontend + 46 Playwright E2E). +**Test Verification Baseline:** **928 Automated Tests** (613 Pytest Backend + 269 Vitest Frontend + 46 Playwright E2E). --- @@ -650,7 +650,7 @@ classDiagram - `test_api_get_symbol_impact_success` - `test_api_get_symbol_impact_not_found` -#### `tests/backend/test_navigator_service.py` (9 tests) +#### `tests/backend/test_navigator_service.py` (11 tests) - `test_db` - `test_navigator_tree_construction` - `test_navigator_tree_all_repos` @@ -660,6 +660,8 @@ classDiagram - `test_file_outline_with_route_mapping_and_cleaning` - `test_symbol_impact_retrieval` - `test_symbol_impact_not_found` +- `test_no_outgoing_calls_in_callers` +- `test_real_codebase_symbol_extraction_and_navigation` #### `tests/backend/test_schemas.py` (2 tests) - `test_code_symbol_creation` @@ -1211,7 +1213,7 @@ and leaves the prior indexed state intact without data loss._ - displays PDF notice and hides text textarea when upload path is a PDF - prevents direct text replacement of PDF files in replace modal -#### `NavigatorInspector.test.tsx` (8 tests) +#### `NavigatorInspector.test.tsx` (9 tests) - renders empty placeholder when no symbol is selected - renders symbol metadata and metrics - renders route details card @@ -1219,6 +1221,7 @@ and leaves the prior indexed state intact without data loss._ - copies permalink to clipboard on button click - calls onSelectCaller when a caller is clicked for click-through navigation - renders outgoing callees and imports +- calls onSelectCallee when a clickable callee is clicked for cross-file navigation - renders loading state when loading is true #### `NavigatorOutline.test.tsx` (8 tests) diff --git a/app/services/navigator.py b/app/services/navigator.py index 225b9e3..2f8670b 100644 --- a/app/services/navigator.py +++ b/app/services/navigator.py @@ -92,18 +92,35 @@ def _format_node(n): def get_file_outline(repo: str, filepath: str) -> Optional[Dict[str, Any]]: clean_fp = _clean_path(filepath) with get_db_connection() as conn: - where = " WHERE filepath LIKE ? " if repo == "__all__" else " WHERE repo = ? AND filepath LIKE ? " - params = [f"%{clean_fp}"] if repo == "__all__" else [repo, f"%{clean_fp}"] - + repo_filter = "" if repo == "__all__" else "repo = ? AND " + repo_params = [] if repo == "__all__" else [repo] + + # First attempt exact filepath match (relative or with leading slash) symbols = conn.execute( - f"SELECT id, repo, filepath, name, full_symbol, kind, start_line, end_line, signature, language FROM ast_symbols{where} ORDER BY start_line ASC", - params + f"SELECT id, repo, filepath, name, full_symbol, kind, start_line, end_line, signature, language " + f"FROM ast_symbols WHERE {repo_filter}(filepath = ? OR filepath = ?) ORDER BY start_line ASC", + repo_params + [clean_fp, f"/{clean_fp}"] ).fetchall() + # If no exact match, fallback to slash-anchored suffix match to prevent cross-file symbol leakage + if not symbols: + symbols = conn.execute( + f"SELECT id, repo, filepath, name, full_symbol, kind, start_line, end_line, signature, language " + f"FROM ast_symbols WHERE {repo_filter}(filepath LIKE ? OR filepath LIKE ?) ORDER BY start_line ASC", + repo_params + [f"%/{clean_fp}", f"%\\{clean_fp}"] + ).fetchall() + routes = conn.execute( - f"SELECT id, framework, http_method, path_pattern, handler_symbol, start_line, end_line FROM api_routes{where}", - params + f"SELECT id, framework, http_method, path_pattern, handler_symbol, start_line, end_line " + f"FROM api_routes WHERE {repo_filter}(filepath = ? OR filepath = ?)", + repo_params + [clean_fp, f"/{clean_fp}"] ).fetchall() + if not routes: + routes = conn.execute( + f"SELECT id, framework, http_method, path_pattern, handler_symbol, start_line, end_line " + f"FROM api_routes WHERE {repo_filter}(filepath LIKE ? OR filepath LIKE ?)", + repo_params + [f"%/{clean_fp}", f"%\\{clean_fp}"] + ).fetchall() route_by_handler = {r["handler_symbol"]: dict(r) for r in routes if r["handler_symbol"]} route_by_line = {r["start_line"]: dict(r) for r in routes} @@ -139,27 +156,105 @@ def get_symbol_impact(repo: str, symbol_id: int) -> Optional[Dict[str, Any]]: if not sym: return None - # Fetch incoming callers - callers = conn.execute( - "SELECT id, source_symbol_id, source_filepath, source_symbol, target_symbol, relationship_type, line_number FROM ast_relationships WHERE target_symbol = ? OR source_symbol_id = ?", - (sym["name"], sym["id"]) - ).fetchall() + sym_repo = sym["repo"] + target_repo = sym_repo if repo != "__all__" else "__all__" + repo_filter_clause = "" if target_repo == "__all__" else " AND r.repo = ?" + repo_params = [] if target_repo == "__all__" else [target_repo] - # Fetch outgoing dependencies - callees = conn.execute( - "SELECT id, target_symbol, relationship_type, line_number FROM ast_relationships WHERE source_symbol_id = ? AND relationship_type != 'IMPORTS'", - (sym["id"],) - ).fetchall() + # 1. Fetch incoming callers: + # Matches relationships where this symbol is called/used. + # Never includes outgoing calls made by this symbol. + # Resolves source_symbol_id from ast_symbols if missing, and groups multiple calls from same caller. + callers_query = f""" + SELECT + MIN(r.id) as id, + COALESCE(r.source_symbol_id, src_sym.id) as source_symbol_id, + r.source_filepath, + r.source_symbol, + r.target_symbol, + r.relationship_type, + MIN(r.line_number) as line_number, + COUNT(*) as call_count, + GROUP_CONCAT(DISTINCT r.line_number) as all_lines + FROM ast_relationships r + LEFT JOIN ( + SELECT id, repo, filepath, name, full_symbol, + ROW_NUMBER() OVER (PARTITION BY repo, filepath, name ORDER BY id ASC) as rn + FROM ast_symbols + ) src_sym ON ( + r.source_symbol_id = src_sym.id + OR (r.source_symbol = src_sym.name AND (r.source_filepath = src_sym.filepath OR r.source_filepath LIKE '%/' || src_sym.filepath) AND r.repo = src_sym.repo) + ) AND src_sym.rn = 1 + WHERE (r.target_symbol = ? OR (r.target_symbol = ? AND ? != '')){repo_filter_clause} + GROUP BY r.source_filepath, r.source_symbol, r.relationship_type + ORDER BY r.source_filepath, MIN(r.line_number) ASC + """ + full_sym = sym["full_symbol"] or "" + caller_params = [sym["name"], full_sym, full_sym] + repo_params + callers = conn.execute(callers_query, caller_params).fetchall() - imports = conn.execute( - "SELECT id, target_symbol, line_number FROM ast_relationships WHERE source_symbol_id = ? AND relationship_type = 'IMPORTS'", - (sym["id"],) - ).fetchall() + # 2. Fetch outgoing dependencies (callees): + # Matches calls originating from this symbol. + # Resolves target_filepath and target_symbol_id from ast_symbols so links work across usages! + # Groups repeated calls to the same target and sorts resolved codebase targets to the top. + callees_query = f""" + SELECT + MIN(r.id) as id, + r.target_symbol, + r.relationship_type, + MIN(r.line_number) as line_number, + COUNT(*) as call_count, + GROUP_CONCAT(DISTINCT r.line_number) as all_lines, + tgt_sym.filepath as target_filepath, + tgt_sym.id as target_symbol_id + FROM ast_relationships r + LEFT JOIN ( + SELECT id, repo, filepath, name, full_symbol, + ROW_NUMBER() OVER (PARTITION BY repo, name ORDER BY id ASC) as rn + FROM ast_symbols + ) tgt_sym ON ( + (r.target_symbol = tgt_sym.name OR r.target_symbol = tgt_sym.full_symbol) + AND (r.repo = tgt_sym.repo OR ? = '__all__') + AND tgt_sym.rn = 1 + ) + WHERE (r.source_symbol_id = ? OR (r.source_symbol = ? AND (r.source_filepath = ? OR r.source_filepath LIKE ?))) + AND r.relationship_type != 'IMPORTS'{repo_filter_clause} + GROUP BY r.target_symbol, r.relationship_type + ORDER BY + CASE WHEN tgt_sym.filepath IS NOT NULL THEN 0 ELSE 1 END ASC, + MIN(r.line_number) ASC + """ + callee_params = [target_repo, sym["id"], sym["name"], sym["filepath"], f"%/{_clean_path(sym['filepath'])}"] + repo_params + callees = conn.execute(callees_query, callee_params).fetchall() - route = conn.execute( - "SELECT framework, http_method, path_pattern FROM api_routes WHERE handler_symbol = ? OR (filepath LIKE ? AND start_line <= ? AND end_line >= ?)", - (sym["name"], f"%{_clean_path(sym['filepath'])}", sym["start_line"], sym["end_line"]) - ).fetchone() + # 3. Fetch imports: + imports_query = f""" + SELECT + MIN(r.id) as id, + r.target_symbol, + MIN(r.line_number) as line_number, + COUNT(*) as import_count, + GROUP_CONCAT(DISTINCT r.line_number) as all_lines + FROM ast_relationships r + WHERE (r.source_symbol_id = ? OR (r.source_symbol = ? AND (r.source_filepath = ? OR r.source_filepath LIKE ?))) + AND r.relationship_type = 'IMPORTS'{repo_filter_clause} + GROUP BY r.target_symbol + ORDER BY MIN(r.line_number) ASC + """ + import_params = [sym["id"], sym["name"], sym["filepath"], f"%/{_clean_path(sym['filepath'])}"] + repo_params + imports = conn.execute(imports_query, import_params).fetchall() + + # 4. Fetch API route mapping: + clean_sym_fp = _clean_path(sym["filepath"]) + route_repo_clause = "" if target_repo == "__all__" else " AND repo = ?" + route_query = f""" + SELECT framework, http_method, path_pattern + FROM api_routes + WHERE (handler_symbol = ? OR (filepath = ? OR filepath = ? OR filepath LIKE ?)) + AND start_line <= ? AND end_line >= ?{route_repo_clause} + """ + route_params = [sym["name"], clean_sym_fp, f"/{clean_sym_fp}", f"%/{clean_sym_fp}", sym["start_line"], sym["end_line"]] + repo_params + route = conn.execute(route_query, route_params).fetchone() return { "symbol": dict(sym), diff --git a/frontend/src/components/navigator/NavigatorInspector.tsx b/frontend/src/components/navigator/NavigatorInspector.tsx index ced8c36..7d5887d 100644 --- a/frontend/src/components/navigator/NavigatorInspector.tsx +++ b/frontend/src/components/navigator/NavigatorInspector.tsx @@ -247,9 +247,16 @@ export const NavigatorInspector: React.FC = ({ {c.source_filepath && ( {c.source_filepath} )} - {c.line_number && ( + {c.all_lines ? ( + + L{c.all_lines.includes(',') ? c.all_lines.split(',').slice(0, 3).join(', L') + (c.all_lines.split(',').length > 3 ? '...' : '') : c.all_lines} + + ) : c.line_number ? ( L{c.line_number} - )} + ) : null} + {c.call_count && c.call_count > 1 ? ( + {c.call_count} calls + ) : null} {c.relationship_type && ( {c.relationship_type} )} @@ -276,24 +283,48 @@ export const NavigatorInspector: React.FC = ({ {callees.map((callee, idx) => (
{ - if (onSelectCallee) { + if (callee.target_filepath && onSelectCallee) { + onSelectCallee(callee.target_filepath, callee.target_symbol); + } + }} + role={callee.target_filepath ? 'button' : undefined} + tabIndex={callee.target_filepath ? 0 : undefined} + title={callee.target_filepath ? `Jump to ${callee.target_symbol} in ${callee.target_filepath}` : undefined} + onKeyDown={(e) => { + if (callee.target_filepath && onSelectCallee && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); onSelectCallee(callee.target_filepath, callee.target_symbol); } }} >
{callee.target_symbol} - {callee.relationship_type || 'CALLS'} +
+ {callee.relationship_type || 'CALLS'} + {callee.target_filepath && ( + + )} +
{callee.target_filepath && ( {callee.target_filepath} )} - {callee.line_number && ( + {callee.all_lines ? ( + + L{callee.all_lines.includes(',') ? callee.all_lines.split(',').slice(0, 3).join(', L') + (callee.all_lines.split(',').length > 3 ? '...' : '') : callee.all_lines} + + ) : callee.line_number ? ( L{callee.line_number} - )} + ) : null} + {callee.call_count && callee.call_count > 1 ? ( + {callee.call_count} calls + ) : null}
))} @@ -304,11 +335,18 @@ export const NavigatorInspector: React.FC = ({ {imp.target_symbol} IMPORTS - {imp.line_number && ( -
+
+ {imp.all_lines ? ( + + L{imp.all_lines.includes(',') ? imp.all_lines.split(',').slice(0, 3).join(', L') + (imp.all_lines.split(',').length > 3 ? '...' : '') : imp.all_lines} + + ) : imp.line_number ? ( L{imp.line_number} -
- )} + ) : null} + {imp.import_count && imp.import_count > 1 ? ( + {imp.import_count} imports + ) : null} +
))} diff --git a/frontend/src/components/navigator/types.ts b/frontend/src/components/navigator/types.ts index 452d69a..af2e597 100644 --- a/frontend/src/components/navigator/types.ts +++ b/frontend/src/components/navigator/types.ts @@ -57,6 +57,8 @@ export interface SymbolCaller { target_symbol?: string; relationship_type?: string; line_number?: number | null; + call_count?: number; + all_lines?: string | null; } export interface SymbolCallee { @@ -65,6 +67,8 @@ export interface SymbolCallee { target_filepath?: string; relationship_type?: string; line_number?: number | null; + call_count?: number; + all_lines?: string | null; } export interface SymbolImport { @@ -72,6 +76,8 @@ export interface SymbolImport { target_symbol: string; relationship_type?: string; line_number?: number | null; + import_count?: number; + all_lines?: string | null; } export interface SymbolDetail { diff --git a/frontend/src/styles/navigator.css b/frontend/src/styles/navigator.css index e853ce8..a2f83fa 100644 --- a/frontend/src/styles/navigator.css +++ b/frontend/src/styles/navigator.css @@ -972,11 +972,13 @@ transition: all 0.15s; } -.relation-item.caller-item { +.relation-item.caller-item, +.relation-item.callee-item.is-clickable { cursor: pointer; } -.relation-item.caller-item:hover { +.relation-item.caller-item:hover, +.relation-item.callee-item.is-clickable:hover { background: rgba(8, 145, 178, 0.18); border-color: var(--accent, #14b8a6); transform: translateX(2px); @@ -988,6 +990,12 @@ justify-content: space-between; } +.rel-top-right { + display: flex; + align-items: center; + gap: 6px; +} + .rel-symbol-name { font-weight: 600; font-size: 0.82rem; @@ -1031,6 +1039,16 @@ color: #c084fc; } +.rel-count-badge { + font-size: 0.65rem; + background: rgba(20, 184, 166, 0.15); + color: var(--accent-light, #2dd4bf); + padding: 1px 5px; + border-radius: 4px; + font-weight: 500; + font-family: var(--font-family-mono, monospace); +} + .nav-no-items-text { font-size: 0.8rem; color: var(--text-muted, #94a3b8); diff --git a/frontend/src/tests/NavigatorInspector.test.tsx b/frontend/src/tests/NavigatorInspector.test.tsx index c463fd9..d980a2a 100644 --- a/frontend/src/tests/NavigatorInspector.test.tsx +++ b/frontend/src/tests/NavigatorInspector.test.tsx @@ -162,6 +162,25 @@ describe('NavigatorInspector Component', () => { expect(screen.getByText('fastapi.APIRouter')).toBeInTheDocument(); }); + it('calls onSelectCallee when a clickable callee is clicked for cross-file navigation', () => { + const handleSelectCallee = vi.fn(); + render( + + ); + + const calleeItem = screen.getByTestId('callee-item-2'); + fireEvent.click(calleeItem); + + expect(handleSelectCallee).toHaveBeenCalledWith( + 'app/services/llm_gateway.py', + 'LiteLLMGateway.execute_call' + ); + }); + it('renders loading state when loading is true', () => { render( = 1 - caller_targets = [c["target_symbol"] for c in res["callers"]] - assert "root_handler" in caller_targets or any(c["source_symbol_id"] == 1 for c in res["callers"]) + assert len(res["callers"]) == 1 + assert res["callers"][0]["source_symbol"] == "caller_fn" + assert res["callers"][0]["target_symbol"] == "root_handler" + for c in res["callers"]: + assert c["source_symbol"] != "root_handler" + # Callees should have resolved target_filepath from ast_symbols so cross-usage links work! assert "callees" in res assert len(res["callees"]) == 1 assert res["callees"][0]["target_symbol"] == "compute_value" assert res["callees"][0]["relationship_type"] == "CALLS" + assert res["callees"][0]["target_filepath"] == "app/services/helper.py" assert "imports" in res assert len(res["imports"]) == 1 @@ -267,3 +273,95 @@ def test_symbol_impact_retrieval(test_db): def test_symbol_impact_not_found(test_db): res = get_symbol_impact("test-repo", 99999) assert res is None + + +def test_no_outgoing_calls_in_callers(test_db): + # compute_value has 1 incoming caller (root_handler in app/main.py) + # and 0 callees / 0 imports + res = get_symbol_impact("test-repo", 2) + assert res is not None + assert res["symbol"]["name"] == "compute_value" + assert len(res["callers"]) == 1 + assert res["callers"][0]["source_symbol"] == "root_handler" + assert res["callers"][0]["source_filepath"] == "app/main.py" + assert len(res["callees"]) == 0 + assert len(res["imports"]) == 0 + + +def test_real_codebase_symbol_extraction_and_navigation(tmp_path): + from app.services.chunking import extract_symbols_and_chunks + + db_file = str(tmp_path / "test_real_nav.db") + conn = sqlite3.connect(db_file) + conn.row_factory = sqlite3.Row + conn.executescript(""" + CREATE TABLE git_repositories (id INTEGER PRIMARY KEY, name TEXT UNIQUE, url TEXT, enabled INTEGER DEFAULT 1, auto_sync INTEGER DEFAULT 1); + CREATE TABLE indexed_paths (id INTEGER PRIMARY KEY, path TEXT, repo TEXT, enabled INTEGER DEFAULT 1); + CREATE TABLE indexed_files (filepath TEXT PRIMARY KEY, repo TEXT, doc_type TEXT, language TEXT); + CREATE TABLE ast_symbols ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo TEXT, filepath TEXT, name TEXT, full_symbol TEXT, kind TEXT, + start_line INTEGER, end_line INTEGER, signature TEXT, language TEXT + ); + CREATE TABLE ast_relationships ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo TEXT, source_symbol_id INTEGER, source_filepath TEXT, + source_symbol TEXT, target_symbol TEXT, relationship_type TEXT, line_number INTEGER + ); + CREATE TABLE api_routes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + repo TEXT, filepath TEXT, framework TEXT, http_method TEXT, path_pattern TEXT, + handler_symbol TEXT, start_line INTEGER, end_line INTEGER + ); + """) + + repo = "contexthub_real" + conn.execute("INSERT INTO git_repositories (name, url) VALUES (?, ?)", (repo, "https://github.com/org/contexthub.git")) + + real_files = ["app/services/navigator.py", "app/api/routers/navigator.py"] + sym_id_map = {} + for rf in real_files: + with open(rf, "r") as f: + code = f.read() + conn.execute("INSERT INTO indexed_files VALUES (?, ?, ?, ?)", (rf, repo, "code", "python")) + res = extract_symbols_and_chunks(code, rf, repo=repo) + for s in res.symbols: + cur = conn.execute( + "INSERT INTO ast_symbols (repo, filepath, name, full_symbol, kind, start_line, end_line, signature, language) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (s.repo, s.filepath, s.name, s.full_symbol, s.kind, s.start_line, s.end_line, s.signature, s.language) + ) + sym_id_map[(s.repo, s.filepath, s.name)] = cur.lastrowid + for r in res.relationships: + src_id = sym_id_map.get((r.repo, r.source_filepath, r.source_symbol)) + conn.execute( + "INSERT INTO ast_relationships (repo, source_symbol_id, source_filepath, source_symbol, target_symbol, relationship_type, line_number) VALUES (?, ?, ?, ?, ?, ?, ?)", + (r.repo, src_id, r.source_filepath, r.source_symbol, r.target_symbol, r.relationship_type, r.line_number) + ) + conn.commit() + conn.close() + + def get_conn(): + c = sqlite3.connect(db_file) + c.row_factory = sqlite3.Row + return c + + with patch("app.services.navigator.get_db_connection", side_effect=get_conn): + outline = get_file_outline(repo, "app/services/navigator.py") + assert outline is not None + assert len(outline["symbols"]) >= 4 + + target_sym = next(s for s in outline["symbols"] if s["name"] == "get_symbol_impact") + impact = get_symbol_impact(repo, target_sym["id"]) + assert impact is not None + + # Verify incoming callers contains api_get_symbol_impact + assert len(impact["callers"]) == 1 + assert impact["callers"][0]["source_symbol"] == "api_get_symbol_impact" + assert impact["callers"][0]["source_filepath"] == "app/api/routers/navigator.py" + + # Verify outgoing callees contains _clean_path resolved to app/services/navigator.py + clean_callee = next(cl for cl in impact["callees"] if cl["target_symbol"] == "_clean_path") + assert clean_callee["target_filepath"] == "app/services/navigator.py" + assert clean_callee["call_count"] >= 1 + +