Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down Expand Up @@ -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`
Expand All @@ -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`
Expand Down Expand Up @@ -1211,14 +1213,15 @@ 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
- renders signature code and docstring
- 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)
Expand Down
145 changes: 120 additions & 25 deletions app/services/navigator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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),
Expand Down
60 changes: 49 additions & 11 deletions frontend/src/components/navigator/NavigatorInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,9 +247,16 @@ export const NavigatorInspector: React.FC<NavigatorInspectorProps> = ({
{c.source_filepath && (
<span className="rel-filepath">{c.source_filepath}</span>
)}
{c.line_number && (
{c.all_lines ? (
<span className="rel-line" title={`Lines: ${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}
</span>
) : c.line_number ? (
<span className="rel-line">L{c.line_number}</span>
)}
) : null}
{c.call_count && c.call_count > 1 ? (
<span className="rel-count-badge">{c.call_count} calls</span>
) : null}
{c.relationship_type && (
<span className="rel-type-tag">{c.relationship_type}</span>
)}
Expand All @@ -276,24 +283,48 @@ export const NavigatorInspector: React.FC<NavigatorInspectorProps> = ({
{callees.map((callee, idx) => (
<div
key={callee.id ?? `callee-${idx}`}
className="relation-item callee-item"
data-testid={`callee-item-${callee.id ?? idx}`}
className={`relation-item callee-item ${callee.target_filepath ? 'is-clickable' : ''}`}
onClick={() => {
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);
}
}}
>
<div className="relation-top">
<span className="rel-symbol-name">{callee.target_symbol}</span>
<span className="rel-type-badge">{callee.relationship_type || 'CALLS'}</span>
<div className="rel-top-right">
<span className="rel-type-badge">{callee.relationship_type || 'CALLS'}</span>
{callee.target_filepath && (
<span className="rel-jump-hint" aria-hidden="true">
Jump ↗
</span>
)}
</div>
</div>
<div className="relation-bottom">
{callee.target_filepath && (
<span className="rel-filepath">{callee.target_filepath}</span>
)}
{callee.line_number && (
{callee.all_lines ? (
<span className="rel-line" title={`Lines: ${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}
</span>
) : callee.line_number ? (
<span className="rel-line">L{callee.line_number}</span>
)}
) : null}
{callee.call_count && callee.call_count > 1 ? (
<span className="rel-count-badge">{callee.call_count} calls</span>
) : null}
</div>
</div>
))}
Expand All @@ -304,11 +335,18 @@ export const NavigatorInspector: React.FC<NavigatorInspectorProps> = ({
<span className="rel-symbol-name">{imp.target_symbol}</span>
<span className="rel-type-badge import-badge">IMPORTS</span>
</div>
{imp.line_number && (
<div className="relation-bottom">
<div className="relation-bottom">
{imp.all_lines ? (
<span className="rel-line" title={`Lines: ${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}
</span>
) : imp.line_number ? (
<span className="rel-line">L{imp.line_number}</span>
</div>
)}
) : null}
{imp.import_count && imp.import_count > 1 ? (
<span className="rel-count-badge">{imp.import_count} imports</span>
) : null}
</div>
</div>
))}
</div>
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/components/navigator/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -65,13 +67,17 @@ export interface SymbolCallee {
target_filepath?: string;
relationship_type?: string;
line_number?: number | null;
call_count?: number;
all_lines?: string | null;
}

export interface SymbolImport {
id?: number;
target_symbol: string;
relationship_type?: string;
line_number?: number | null;
import_count?: number;
all_lines?: string | null;
}

export interface SymbolDetail {
Expand Down
22 changes: 20 additions & 2 deletions frontend/src/styles/navigator.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading