Skip to main content

aft/
callgraph.rs

1//! Call graph engine: cross-file call resolution and forward traversal.
2//!
3//! Builds a lazy, worktree-scoped call graph that resolves calls across files
4//! using import chains. Supports depth-limited forward traversal with cycle
5//! detection.
6
7use std::cell::RefCell;
8use std::collections::{HashMap, HashSet, VecDeque};
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, LazyLock, RwLock};
11use std::time::Instant;
12
13use globset::{Glob, GlobSet, GlobSetBuilder};
14use rayon::prelude::*;
15use serde::Serialize;
16use serde_json::Value;
17use tree_sitter::{Node, Parser};
18
19use crate::calls::{call_node_kinds, extract_callee_name, extract_calls_full, extract_full_callee};
20use crate::edit::line_col_to_byte;
21use crate::error::AftError;
22use crate::imports::{self, ImportBlock};
23use crate::language::LanguageProvider;
24use crate::parser::{detect_language, grammar_for, LangId};
25use crate::symbols::{Range, Symbol, SymbolKind};
26use crate::{slog_debug, slog_info};
27
28// ---------------------------------------------------------------------------
29// Core types
30// ---------------------------------------------------------------------------
31
32type SharedPath = Arc<PathBuf>;
33type SharedStr = Arc<str>;
34type ReverseIndex = HashMap<PathBuf, HashMap<String, Vec<IndexedCallerSite>>>;
35type WorkspacePackageCache = HashMap<(PathBuf, String), Option<PathBuf>>;
36type RustCrateInfoCache = HashMap<PathBuf, Option<RustCrateInfo>>;
37type RustWorkspaceCrateCache = HashMap<PathBuf, HashMap<String, RustCrateInfo>>;
38
39static WORKSPACE_PACKAGE_CACHE: LazyLock<RwLock<WorkspacePackageCache>> =
40    LazyLock::new(|| RwLock::new(HashMap::new()));
41static RUST_CRATE_INFO_CACHE: LazyLock<RwLock<RustCrateInfoCache>> =
42    LazyLock::new(|| RwLock::new(HashMap::new()));
43static RUST_WORKSPACE_CRATE_CACHE: LazyLock<RwLock<RustWorkspaceCrateCache>> =
44    LazyLock::new(|| RwLock::new(HashMap::new()));
45
46const TOP_LEVEL_SYMBOL: &str = "<top-level>";
47const JS_TS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
48const JS_TS_INDEX_FILES: &[&str] = &[
49    "index.ts",
50    "index.tsx",
51    "index.mts",
52    "index.cts",
53    "index.js",
54    "index.jsx",
55    "index.mjs",
56    "index.cjs",
57];
58
59fn symbol_identity(symbol: &Symbol) -> String {
60    if symbol.scope_chain.is_empty() {
61        symbol.name.clone()
62    } else {
63        format!("{}::{}", symbol.scope_chain.join("::"), symbol.name)
64    }
65}
66
67fn symbol_unqualified_name(symbol: &str) -> &str {
68    symbol.rsplit("::").next().unwrap_or(symbol)
69}
70
71fn symbol_query_matches(symbol: &str, query: &str) -> bool {
72    symbol == query || symbol_unqualified_name(symbol) == query
73}
74
75pub(crate) fn is_bare_callee(full_callee: &str, short_name: &str) -> bool {
76    full_callee == short_name || (!full_callee.contains('.') && !full_callee.contains("::"))
77}
78
79fn symbol_query_candidates(file_data: &FileCallData, symbol_name: &str) -> Vec<String> {
80    let mut seen = HashSet::new();
81    let mut candidates = Vec::new();
82    let qualified_query = symbol_name.contains("::");
83
84    let mut consider = |candidate: &str| {
85        let matches = if qualified_query {
86            candidate == symbol_name
87        } else {
88            candidate == symbol_name || symbol_unqualified_name(candidate) == symbol_name
89        };
90
91        if matches && seen.insert(candidate.to_string()) {
92            candidates.push(candidate.to_string());
93        }
94    };
95
96    for candidate in file_data.symbol_metadata.keys() {
97        consider(candidate);
98    }
99    for candidate in file_data.calls_by_symbol.keys() {
100        consider(candidate);
101    }
102    for candidate in &file_data.exported_symbols {
103        consider(candidate);
104    }
105
106    candidates.sort();
107    candidates
108}
109
110pub(crate) fn resolve_symbol_query_in_data(
111    file_data: &FileCallData,
112    file: &Path,
113    symbol_name: &str,
114) -> Result<String, AftError> {
115    let candidates = symbol_query_candidates(file_data, symbol_name);
116    match candidates.as_slice() {
117        [candidate] => Ok(candidate.clone()),
118        [] => Err(AftError::SymbolNotFound {
119            name: symbol_name.to_string(),
120            file: file.display().to_string(),
121        }),
122        _ => Err(AftError::AmbiguousSymbol {
123            name: symbol_name.to_string(),
124            candidates,
125        }),
126    }
127}
128
129/// A single call site within a function body.
130#[derive(Debug, Clone)]
131pub struct CallSite {
132    /// The short callee name (last segment, e.g. "foo" for `utils.foo()`).
133    pub callee_name: String,
134    /// The full callee expression (e.g. "utils.foo" for `utils.foo()`).
135    pub full_callee: String,
136    /// 1-based line number of the call.
137    pub line: u32,
138    /// Byte range of the call expression in the source.
139    pub byte_start: usize,
140    pub byte_end: usize,
141}
142
143/// Per-symbol metadata for entry point detection (avoids re-parsing).
144#[derive(Debug, Clone, Serialize)]
145pub struct SymbolMeta {
146    /// The kind of symbol (function, class, method, etc).
147    pub kind: SymbolKind,
148    /// Whether this symbol is exported.
149    pub exported: bool,
150    /// Function/method signature if available.
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub signature: Option<String>,
153    /// 1-based start line of the symbol.
154    pub line: u32,
155    /// 0-based source range of the symbol.
156    pub range: Range,
157}
158
159/// Per-file call data: call sites grouped by containing symbol, plus
160/// exported symbol names and parsed imports.
161#[derive(Debug, Clone)]
162pub struct FileCallData {
163    /// Map from symbol name → list of call sites within that symbol's body.
164    pub calls_by_symbol: HashMap<String, Vec<CallSite>>,
165    /// Names of exported symbols in this file.
166    pub exported_symbols: Vec<String>,
167    /// Per-symbol metadata (kind, exported, signature).
168    pub symbol_metadata: HashMap<String, SymbolMeta>,
169    /// Real or synthetic symbol name for this file's default export.
170    pub default_export_symbol: Option<String>,
171    /// Parsed import block for cross-file resolution.
172    pub import_block: ImportBlock,
173    /// Language of the file.
174    pub lang: LangId,
175}
176
177impl FileCallData {
178    /// Look up metadata for an exported symbol name.
179    ///
180    /// `exported_symbols` stores bare names (e.g. `total_disk_bytes`), but
181    /// `symbol_metadata` is keyed by scoped identity (e.g.
182    /// `BackupStore::total_disk_bytes` for impl methods, via
183    /// [`symbol_identity`]). A bare-name `.get()` therefore misses scoped
184    /// symbols and forces callers into degraded `unknown`/line-1 fallbacks.
185    /// This resolves an exact key first, then falls back to the first entry
186    /// whose unqualified name matches — recovering correct kind and line for
187    /// methods. (Bare-name exports are already ambiguous across scopes, so
188    /// first-match is the best available signal; this only affects displayed
189    /// metadata, never liveness, which keys on the symbol name.)
190    pub fn symbol_metadata_for(&self, name: &str) -> Option<&SymbolMeta> {
191        if let Some(meta) = self.symbol_metadata.get(name) {
192            return Some(meta);
193        }
194        self.symbol_metadata
195            .iter()
196            .find(|(key, _)| symbol_unqualified_name(key) == name)
197            .map(|(_, meta)| meta)
198    }
199}
200
201/// Result of resolving a cross-file call edge.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub enum EdgeResolution {
204    /// Successfully resolved to a specific file and symbol.
205    Resolved { file: PathBuf, symbol: String },
206    /// Could not resolve — callee name preserved for diagnostics.
207    Unresolved { callee_name: String },
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
211struct ResolvedSymbol {
212    file: PathBuf,
213    symbol: String,
214}
215
216#[derive(Debug, Clone)]
217struct RustCrateInfo {
218    lib_name: String,
219    lib_root: Option<PathBuf>,
220    main_root: Option<PathBuf>,
221}
222
223#[derive(Debug, Clone)]
224struct RustModuleBase {
225    src_dir: PathBuf,
226    root_file: PathBuf,
227}
228
229#[derive(Debug, Clone)]
230struct RustUseEntry {
231    module_path: String,
232    local_name: String,
233    kind: RustUseKind,
234}
235
236#[derive(Debug, Clone)]
237enum RustUseKind {
238    Item { imported_name: String },
239    Module,
240}
241
242/// A single caller site: who calls a given symbol and from where.
243#[derive(Debug, Clone, Serialize)]
244pub struct CallerSite {
245    /// File containing the caller.
246    pub caller_file: PathBuf,
247    /// Symbol that makes the call.
248    pub caller_symbol: String,
249    /// 1-based line number of the call.
250    pub line: u32,
251    /// 0-based column (byte start within file, kept for future use).
252    pub col: u32,
253    /// Whether the edge was resolved via import chain.
254    pub resolved: bool,
255}
256
257#[derive(Debug, Clone)]
258struct IndexedCallerSite {
259    caller_file: SharedPath,
260    caller_symbol: SharedStr,
261    line: u32,
262    col: u32,
263    resolved: bool,
264}
265
266/// A group of callers from a single file.
267#[derive(Debug, Clone, Serialize)]
268pub struct CallerGroup {
269    /// File path (relative to project root).
270    pub file: String,
271    /// Individual call sites in this file.
272    pub callers: Vec<CallerEntry>,
273}
274
275/// A single caller entry within a CallerGroup.
276#[derive(Debug, Clone, Serialize)]
277pub struct CallerEntry {
278    pub symbol: String,
279    /// 1-based line number of the call.
280    pub line: u32,
281}
282
283/// Result of a `callers_of` query.
284#[derive(Debug, Clone, Serialize)]
285pub struct CallersResult {
286    /// Target symbol queried.
287    pub symbol: String,
288    /// Target file queried.
289    pub file: String,
290    /// Caller groups, one per calling file.
291    pub callers: Vec<CallerGroup>,
292    /// Total number of call sites found.
293    pub total_callers: usize,
294    /// Number of files scanned to build the reverse index.
295    pub scanned_files: usize,
296    /// Whether recursive caller expansion stopped at the requested depth.
297    pub depth_limited: bool,
298    /// Number of caller edges omitted because of the depth limit.
299    pub truncated: usize,
300}
301
302/// A node in the forward call tree.
303#[derive(Debug, Clone, Serialize)]
304pub struct CallTreeNode {
305    /// Symbol name.
306    pub name: String,
307    /// File path (relative to project root when possible).
308    pub file: String,
309    /// 1-based line number.
310    pub line: u32,
311    /// Function signature if available.
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub signature: Option<String>,
314    /// Whether this edge was resolved cross-file.
315    pub resolved: bool,
316    /// Child calls (recursive).
317    pub children: Vec<CallTreeNode>,
318    /// Whether traversal below this node stopped at the requested depth.
319    pub depth_limited: bool,
320    /// Number of child call edges omitted because of the depth limit.
321    pub truncated: usize,
322}
323
324// ---------------------------------------------------------------------------
325// Entry point detection
326// ---------------------------------------------------------------------------
327
328/// Well-known main/init function names (case-insensitive exact match).
329const MAIN_INIT_NAMES: &[&str] = &["main", "init", "setup", "bootstrap", "run"];
330
331/// Determine whether a symbol is an entry point.
332///
333/// Entry points are:
334/// - Exported standalone functions (not methods — methods are class members)
335/// - Functions matching well-known main/init patterns (any language)
336/// - Test functions matching language-specific patterns
337pub fn is_entry_point(name: &str, kind: &SymbolKind, exported: bool, lang: LangId) -> bool {
338    // Exported standalone functions
339    if exported && *kind == SymbolKind::Function {
340        return true;
341    }
342
343    // Main/init patterns (case-insensitive exact match, any kind)
344    let lower = name.to_lowercase();
345    if MAIN_INIT_NAMES.contains(&lower.as_str()) {
346        return true;
347    }
348
349    // Test patterns by language
350    match lang {
351        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
352            // describe, it, test (exact), or starts with test/spec
353            matches!(lower.as_str(), "describe" | "it" | "test")
354                || lower.starts_with("test")
355                || lower.starts_with("spec")
356        }
357        LangId::Python => {
358            // starts with test_ or matches setUp/tearDown
359            lower.starts_with("test_") || matches!(name, "setUp" | "tearDown")
360        }
361        LangId::Rust => {
362            // starts with test_
363            lower.starts_with("test_")
364        }
365        LangId::Go => {
366            // starts with Test (case-sensitive)
367            name.starts_with("Test")
368        }
369        LangId::C
370        | LangId::Cpp
371        | LangId::Zig
372        | LangId::CSharp
373        | LangId::Bash
374        | LangId::Solidity
375        | LangId::Scss
376        | LangId::Vue
377        | LangId::Json
378        | LangId::Scala
379        | LangId::Java
380        | LangId::Ruby
381        | LangId::Kotlin
382        | LangId::Swift
383        | LangId::Php
384        | LangId::Lua
385        | LangId::Perl
386        | LangId::Html
387        | LangId::Markdown
388        | LangId::Yaml => false,
389    }
390}
391
392// ---------------------------------------------------------------------------
393// Trace-to types
394// ---------------------------------------------------------------------------
395
396/// A single hop in a trace path.
397#[derive(Debug, Clone, Serialize)]
398pub struct TraceHop {
399    /// Symbol name at this hop.
400    pub symbol: String,
401    /// File path (relative to project root).
402    pub file: String,
403    /// 1-based line number.
404    pub line: u32,
405    /// Function signature if available.
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub signature: Option<String>,
408    /// Whether this hop is an entry point.
409    pub is_entry_point: bool,
410}
411
412/// A complete path from an entry point to the target symbol (top-down).
413#[derive(Debug, Clone, Serialize)]
414pub struct TracePath {
415    /// Hops from entry point (first) to target (last).
416    pub hops: Vec<TraceHop>,
417}
418
419/// Result of a `trace_to` query.
420#[derive(Debug, Clone, Serialize)]
421pub struct TraceToResult {
422    /// The target symbol that was traced.
423    pub target_symbol: String,
424    /// The target file (relative to project root).
425    pub target_file: String,
426    /// Complete paths from entry points to the target.
427    pub paths: Vec<TracePath>,
428    /// Total number of complete paths found.
429    pub total_paths: usize,
430    /// Number of distinct entry points found across all paths.
431    pub entry_points_found: usize,
432    /// Whether any path was cut short by the depth limit.
433    pub max_depth_reached: bool,
434    /// Number of paths that reached a dead end (no callers, not entry point).
435    pub truncated_paths: usize,
436}
437
438/// A single hop in a `trace_to_symbol` path.
439#[derive(Debug, Clone, Serialize)]
440pub struct TraceToSymbolHop {
441    /// Symbol name at this hop.
442    pub symbol: String,
443    /// File path (relative to project root).
444    pub file: String,
445    /// 1-based definition line number.
446    pub line: u32,
447}
448
449/// Candidate target location for an ambiguous `trace_to_symbol` request.
450#[derive(Debug, Clone, Serialize)]
451pub struct TraceToSymbolCandidate {
452    /// File path (relative to project root).
453    pub file: String,
454    /// 1-based definition line number.
455    pub line: u32,
456}
457
458/// Result of a `trace_to_symbol` query.
459#[derive(Debug, Clone, Serialize)]
460pub struct TraceToSymbolResult {
461    /// Shortest path from the origin symbol to the target symbol, if found.
462    pub path: Option<Vec<TraceToSymbolHop>>,
463    /// Whether traversal was complete within the requested depth.
464    pub complete: bool,
465    /// Machine-readable explanation when `path` is null.
466    #[serde(skip_serializing_if = "Option::is_none")]
467    pub reason: Option<String>,
468}
469
470// ---------------------------------------------------------------------------
471// Impact analysis types
472// ---------------------------------------------------------------------------
473
474/// A single caller in an impact analysis result.
475#[derive(Debug, Clone, Serialize)]
476pub struct ImpactCaller {
477    /// Symbol that calls the target.
478    pub caller_symbol: String,
479    /// File containing the caller (relative to project root).
480    pub caller_file: String,
481    /// 1-based line number of the call site.
482    pub line: u32,
483    /// Caller's function/method signature, if available.
484    #[serde(skip_serializing_if = "Option::is_none")]
485    pub signature: Option<String>,
486    /// Whether the caller is an entry point.
487    pub is_entry_point: bool,
488    /// Source line at the call site (trimmed).
489    #[serde(skip_serializing_if = "Option::is_none")]
490    pub call_expression: Option<String>,
491    /// Parameter names extracted from the caller's signature.
492    pub parameters: Vec<String>,
493}
494
495/// Result of an `impact` query — enriched callers analysis.
496#[derive(Debug, Clone, Serialize)]
497pub struct ImpactResult {
498    /// The target symbol being analyzed.
499    pub symbol: String,
500    /// The target file (relative to project root).
501    pub file: String,
502    /// Target symbol's signature, if available.
503    #[serde(skip_serializing_if = "Option::is_none")]
504    pub signature: Option<String>,
505    /// Parameter names extracted from the target's signature.
506    pub parameters: Vec<String>,
507    /// Total number of affected call sites.
508    pub total_affected: usize,
509    /// Number of distinct files containing callers.
510    pub affected_files: usize,
511    /// Enriched caller details.
512    pub callers: Vec<ImpactCaller>,
513    /// Whether transitive impact expansion stopped at the requested depth.
514    pub depth_limited: bool,
515    /// Number of caller edges omitted because of the depth limit.
516    pub truncated: usize,
517}
518
519// ---------------------------------------------------------------------------
520// Data flow tracking types
521// ---------------------------------------------------------------------------
522
523/// A single hop in a data flow trace.
524#[derive(Debug, Clone, Serialize)]
525pub struct DataFlowHop {
526    /// File path (relative to project root).
527    pub file: String,
528    /// Symbol (function/method) containing this hop.
529    pub symbol: String,
530    /// Variable or parameter name being tracked at this hop.
531    pub variable: String,
532    /// 1-based line number.
533    pub line: u32,
534    /// Type of data flow: "assignment", "parameter", or "return".
535    pub flow_type: String,
536    /// Whether this hop is an approximation (destructuring, spread, unresolved).
537    pub approximate: bool,
538}
539
540/// Result of a `trace_data` query — tracks how an expression flows through
541/// variable assignments and function parameters.
542#[derive(Debug, Clone, Serialize)]
543pub struct TraceDataResult {
544    /// The expression being tracked.
545    pub expression: String,
546    /// The file where tracking started.
547    pub origin_file: String,
548    /// The symbol where tracking started.
549    pub origin_symbol: String,
550    /// Hops through assignments and parameters.
551    pub hops: Vec<DataFlowHop>,
552    /// Whether tracking stopped due to depth limit.
553    pub depth_limited: bool,
554}
555
556/// Extract parameter names from a function signature string.
557///
558/// Strips language-specific receivers (`self`, `&self`, `&mut self` for Rust,
559/// `self` for Python) and type annotations / default values. Returns just
560/// the parameter names.
561pub fn extract_parameters(signature: &str, lang: LangId) -> Vec<String> {
562    // Find the parameter list between parentheses
563    let start = match signature.find('(') {
564        Some(i) => i + 1,
565        None => return Vec::new(),
566    };
567    let end = match signature[start..].find(')') {
568        Some(i) => start + i,
569        None => return Vec::new(),
570    };
571
572    let params_str = &signature[start..end].trim();
573    if params_str.is_empty() {
574        return Vec::new();
575    }
576
577    // Split on commas, respecting nested generics/brackets
578    let parts = split_params(params_str);
579
580    let mut result = Vec::new();
581    for part in parts {
582        let trimmed = part.trim();
583        if trimmed.is_empty() {
584            continue;
585        }
586
587        // Skip language-specific receivers
588        match lang {
589            LangId::Rust => {
590                if trimmed == "self"
591                    || trimmed == "mut self"
592                    || trimmed.starts_with("&self")
593                    || trimmed.starts_with("&mut self")
594                {
595                    continue;
596                }
597            }
598            LangId::Python => {
599                if trimmed == "self" || trimmed.starts_with("self:") {
600                    continue;
601                }
602            }
603            _ => {}
604        }
605
606        // Extract just the parameter name
607        let name = extract_param_name(trimmed, lang);
608        if !name.is_empty() {
609            result.push(name);
610        }
611    }
612
613    result
614}
615
616/// Split parameter string on commas, respecting nested brackets/generics.
617fn split_params(s: &str) -> Vec<String> {
618    let mut parts = Vec::new();
619    let mut current = String::new();
620    let mut depth = 0i32;
621
622    for ch in s.chars() {
623        match ch {
624            '<' | '[' | '{' | '(' => {
625                depth += 1;
626                current.push(ch);
627            }
628            '>' | ']' | '}' | ')' => {
629                depth -= 1;
630                current.push(ch);
631            }
632            ',' if depth == 0 => {
633                parts.push(current.clone());
634                current.clear();
635            }
636            _ => {
637                current.push(ch);
638            }
639        }
640    }
641    if !current.is_empty() {
642        parts.push(current);
643    }
644    parts
645}
646
647/// Extract the parameter name from a single parameter declaration.
648///
649/// Handles:
650/// - TS/JS: `name: Type`, `name = default`, `...name`, `name?: Type`
651/// - Python: `name: Type`, `name=default`, `*args`, `**kwargs`
652/// - Rust: `name: Type`, `mut name: Type`
653/// - Go: `name Type`, `name, name2 Type`
654fn extract_param_name(param: &str, lang: LangId) -> String {
655    let trimmed = param.trim();
656
657    // Handle rest/spread params
658    let working = if trimmed.starts_with("...") {
659        &trimmed[3..]
660    } else if trimmed.starts_with("**") {
661        &trimmed[2..]
662    } else if trimmed.starts_with('*') && lang == LangId::Python {
663        &trimmed[1..]
664    } else {
665        trimmed
666    };
667
668    // Rust: `mut name: Type` → strip `mut `
669    let working = if lang == LangId::Rust && working.starts_with("mut ") {
670        &working[4..]
671    } else {
672        working
673    };
674
675    // Strip type annotation (`: Type`) and default values (`= default`)
676    // Take only the name part — everything before `:`, `=`, or `?`
677    let name = working
678        .split(|c: char| c == ':' || c == '=')
679        .next()
680        .unwrap_or("")
681        .trim();
682
683    // Strip trailing `?` (optional params in TS)
684    let name = name.trim_end_matches('?');
685
686    // For Go, the name might be just `name Type` — take the first word
687    if lang == LangId::Go && !name.contains(' ') {
688        return name.to_string();
689    }
690    if lang == LangId::Go {
691        return name.split_whitespace().next().unwrap_or("").to_string();
692    }
693
694    name.to_string()
695}
696
697// ---------------------------------------------------------------------------
698// CallGraph
699// ---------------------------------------------------------------------------
700
701/// Worktree-scoped call graph with lazy per-file construction.
702///
703/// Files are parsed and analyzed on first access, then cached. The graph
704/// can resolve cross-file call edges using the import engine.
705pub struct CallGraph {
706    /// Cached per-file call data.
707    data: HashMap<PathBuf, FileCallData>,
708    /// Project root for relative path resolution.
709    project_root: PathBuf,
710    /// All files discovered in the worktree (lazily populated).
711    project_files: Option<Vec<PathBuf>>,
712    /// Reverse index: target_file → target_symbol → callers.
713    /// Built lazily on first `callers_of` call, cleared on `invalidate_file`.
714    reverse_index: Option<ReverseIndex>,
715    /// Memoized `std::fs::canonicalize` results. canonicalize is a realpath
716    /// syscall (disk I/O, slow on large repos / Windows) and the same file paths
717    /// are canonicalized repeatedly across reverse-index builds (every cold
718    /// callers/impact/trace query and after each invalidation). A file's
719    /// canonical form is stable for the life of the graph, so cache it.
720    canon_cache: RefCell<HashMap<PathBuf, Arc<PathBuf>>>,
721}
722
723impl CallGraph {
724    /// Create a new call graph for a project.
725    pub fn new(project_root: PathBuf) -> Self {
726        clear_workspace_package_cache();
727        Self {
728            data: HashMap::new(),
729            project_root,
730            project_files: None,
731            reverse_index: None,
732            canon_cache: RefCell::new(HashMap::new()),
733        }
734    }
735
736    /// Canonicalize a path, memoized. The first call does the realpath syscall;
737    /// repeat calls for the same path (common across reverse-index rebuilds)
738    /// return the cached `Arc` without touching disk. Falls back to the input
739    /// path when canonicalize fails (e.g. the file was deleted), same as the
740    /// previous inline behavior.
741    fn canonicalize_cached(&self, path: &Path) -> Arc<PathBuf> {
742        if let Some(hit) = self.canon_cache.borrow().get(path) {
743            return Arc::clone(hit);
744        }
745        match std::fs::canonicalize(path) {
746            Ok(canon) => {
747                let canon = Arc::new(canon);
748                self.canon_cache
749                    .borrow_mut()
750                    .insert(path.to_path_buf(), Arc::clone(&canon));
751                canon
752            }
753            // Do NOT cache the fallback: canonicalize fails for a (temporarily)
754            // missing file, and caching the non-canonical input would serve a
755            // stale path if the file returns. Re-resolve next time instead.
756            Err(_) => Arc::new(path.to_path_buf()),
757        }
758    }
759
760    /// Get the project root directory.
761    pub fn project_root(&self) -> &Path {
762        &self.project_root
763    }
764
765    fn resolve_cross_file_edge_with_exports<F, D>(
766        full_callee: &str,
767        short_name: &str,
768        caller_file: &Path,
769        import_block: &ImportBlock,
770        mut file_exports_symbol: F,
771        mut file_default_export_symbol: D,
772    ) -> EdgeResolution
773    where
774        F: FnMut(&Path, &str) -> bool,
775        D: FnMut(&Path) -> Option<String>,
776    {
777        let caller_dir = caller_file.parent().unwrap_or(Path::new("."));
778
779        // Rust uses `::` module paths rather than JS/TS specifiers. Keep this
780        // branch gated to `.rs` callers so the existing JS/TS resolver below
781        // remains unchanged.
782        if is_rust_source_file(caller_file) {
783            if let Some(target) = resolve_rust_cross_file_edge(
784                full_callee,
785                short_name,
786                caller_file,
787                import_block,
788                &mut file_exports_symbol,
789            ) {
790                return EdgeResolution::Resolved {
791                    file: target.file,
792                    symbol: target.symbol,
793                };
794            }
795        }
796
797        // Check namespace imports: "utils.foo" where utils is a namespace import
798        if full_callee.contains('.') {
799            let parts: Vec<&str> = full_callee.splitn(2, '.').collect();
800            if parts.len() == 2 {
801                let namespace = parts[0];
802                let member = parts[1];
803
804                for imp in &import_block.imports {
805                    if imp.namespace_import.as_deref() == Some(namespace) {
806                        if let Some(resolved_path) =
807                            resolve_module_path(caller_dir, &imp.module_path)
808                        {
809                            if let Some(target) = resolve_reexported_symbol(
810                                &resolved_path,
811                                member,
812                                &mut file_exports_symbol,
813                                &mut file_default_export_symbol,
814                            ) {
815                                return EdgeResolution::Resolved {
816                                    file: target.file,
817                                    symbol: target.symbol,
818                                };
819                            }
820                        }
821                    }
822                }
823            }
824        }
825
826        // Check named imports (direct and aliased)
827        for imp in &import_block.imports {
828            // Direct named import: import { foo } from './utils'
829            if imp.names.iter().any(|name| name == short_name) {
830                if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
831                    let target = resolve_reexported_symbol(
832                        &resolved_path,
833                        short_name,
834                        &mut file_exports_symbol,
835                        &mut file_default_export_symbol,
836                    )
837                    .unwrap_or(ResolvedSymbol {
838                        file: resolved_path,
839                        symbol: short_name.to_owned(),
840                    });
841                    return EdgeResolution::Resolved {
842                        file: target.file,
843                        symbol: target.symbol,
844                    };
845                }
846            }
847
848            // Default import: import foo from './utils'
849            if imp.default_import.as_deref() == Some(short_name) {
850                if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
851                    let target = resolve_reexported_symbol(
852                        &resolved_path,
853                        "default",
854                        &mut file_exports_symbol,
855                        &mut file_default_export_symbol,
856                    )
857                    .unwrap_or_else(|| ResolvedSymbol {
858                        symbol: file_default_export_symbol(&resolved_path)
859                            .unwrap_or_else(|| synthetic_default_symbol(&resolved_path)),
860                        file: resolved_path,
861                    });
862                    return EdgeResolution::Resolved {
863                        file: target.file,
864                        symbol: target.symbol,
865                    };
866                }
867            }
868        }
869
870        // Check aliased imports by examining the raw import text.
871        // ImportStatement.names stores the original name (foo), but the local code
872        // uses the alias (bar). We need to parse `import { foo as bar }` to find
873        // that `bar` maps to `foo`.
874        if let Some((original_name, resolved_path)) =
875            resolve_aliased_import(short_name, import_block, caller_dir)
876        {
877            let target = resolve_reexported_symbol(
878                &resolved_path,
879                &original_name,
880                &mut file_exports_symbol,
881                &mut file_default_export_symbol,
882            )
883            .unwrap_or(ResolvedSymbol {
884                file: resolved_path,
885                symbol: original_name,
886            });
887            return EdgeResolution::Resolved {
888                file: target.file,
889                symbol: target.symbol,
890            };
891        }
892
893        // Try barrel file re-exports: if any import points to an index file,
894        // check if that file re-exports the symbol
895        for imp in &import_block.imports {
896            if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
897                // Check if the resolved path is a directory (barrel file)
898                if resolved_path.is_dir() {
899                    if let Some(index_path) = find_index_file(&resolved_path) {
900                        // Check if the index file exports this symbol
901                        if file_exports_symbol(&index_path, short_name) {
902                            return EdgeResolution::Resolved {
903                                file: index_path,
904                                symbol: short_name.to_owned(),
905                            };
906                        }
907                    }
908                } else if file_exports_symbol(&resolved_path, short_name) {
909                    return EdgeResolution::Resolved {
910                        file: resolved_path,
911                        symbol: short_name.to_owned(),
912                    };
913                }
914            }
915        }
916
917        EdgeResolution::Unresolved {
918            callee_name: short_name.to_owned(),
919        }
920    }
921
922    /// Get or build the call data for a file.
923    pub fn build_file(&mut self, path: &Path) -> Result<&FileCallData, AftError> {
924        let canon = self.canonicalize(path)?;
925
926        if !self.data.contains_key(&canon) {
927            let file_data = build_file_data(&canon)?;
928            self.data.insert(canon.clone(), file_data);
929        }
930
931        Ok(&self.data[&canon])
932    }
933
934    /// Resolve a user-provided symbol query to the unique scoped symbol identity
935    /// used internally by the call graph.
936    pub fn resolve_symbol_query(&mut self, file: &Path, symbol: &str) -> Result<String, AftError> {
937        let canon = self.canonicalize(file)?;
938        let file_data = self.build_file(&canon)?;
939        resolve_symbol_query_in_data(file_data, &canon, symbol)
940    }
941
942    /// Resolve a cross-file call edge.
943    ///
944    /// Given a callee expression and the calling file's import block,
945    /// determines which file and symbol the call targets.
946    pub fn resolve_cross_file_edge(
947        &mut self,
948        full_callee: &str,
949        short_name: &str,
950        caller_file: &Path,
951        import_block: &ImportBlock,
952    ) -> EdgeResolution {
953        let graph = RefCell::new(self);
954        Self::resolve_cross_file_edge_with_exports(
955            full_callee,
956            short_name,
957            caller_file,
958            import_block,
959            |path, symbol_name| graph.borrow_mut().file_exports_symbol(path, symbol_name),
960            |path| graph.borrow_mut().file_default_export_symbol(path),
961        )
962    }
963
964    /// Check if a file exports a given symbol name.
965    fn file_exports_symbol(&mut self, path: &Path, symbol_name: &str) -> bool {
966        match self.build_file(path) {
967            Ok(data) => data.exported_symbols.iter().any(|name| name == symbol_name),
968            Err(_) => false,
969        }
970    }
971
972    fn file_default_export_symbol(&mut self, path: &Path) -> Option<String> {
973        self.build_file(path)
974            .ok()
975            .and_then(|data| data.default_export_symbol.clone())
976    }
977
978    fn file_exports_symbol_cached(&self, path: &Path, symbol_name: &str) -> bool {
979        self.lookup_file_data(path)
980            .map(|data| data.exported_symbols.iter().any(|name| name == symbol_name))
981            .unwrap_or(false)
982    }
983
984    fn file_default_export_symbol_cached(&self, path: &Path) -> Option<String> {
985        self.lookup_file_data(path)
986            .and_then(|data| data.default_export_symbol.clone())
987    }
988
989    /// Depth-limited forward call tree traversal.
990    ///
991    /// Starting from a (file, symbol) pair, recursively follows calls
992    /// up to `max_depth` levels. Uses a visited set for cycle detection.
993    pub fn forward_tree(
994        &mut self,
995        file: &Path,
996        symbol: &str,
997        max_depth: usize,
998    ) -> Result<CallTreeNode, AftError> {
999        let canon = self.canonicalize(file)?;
1000        let resolved_symbol = {
1001            let file_data = self.build_file(&canon)?;
1002            resolve_symbol_query_in_data(file_data, &canon, symbol)?
1003        };
1004        let mut visited = HashSet::new();
1005        self.forward_tree_inner(&canon, &resolved_symbol, max_depth, 0, &mut visited)
1006    }
1007
1008    fn forward_tree_inner(
1009        &mut self,
1010        file: &Path,
1011        symbol: &str,
1012        max_depth: usize,
1013        current_depth: usize,
1014        visited: &mut HashSet<(PathBuf, String)>,
1015    ) -> Result<CallTreeNode, AftError> {
1016        let canon = self.canonicalize(file)?;
1017        let visit_key = (canon.clone(), symbol.to_string());
1018
1019        // Cycle detection
1020        if visited.contains(&visit_key) {
1021            let (line, signature) = self
1022                .lookup_file_data(&canon)
1023                .map(|data| get_symbol_meta_from_data(data, symbol))
1024                .unwrap_or_else(|| get_symbol_meta(&canon, symbol));
1025            return Ok(CallTreeNode {
1026                name: symbol.to_string(),
1027                file: self.relative_path(&canon),
1028                line,
1029                signature,
1030                resolved: true,
1031                children: vec![], // cycle — stop recursion
1032                depth_limited: false,
1033                truncated: 0,
1034            });
1035        }
1036
1037        visited.insert(visit_key.clone());
1038
1039        let (import_block, call_sites, sym_line, sym_signature) = {
1040            let file_data = self.build_file(&canon)?;
1041            let meta = get_symbol_meta_from_data(file_data, symbol);
1042
1043            (
1044                file_data.import_block.clone(),
1045                file_data
1046                    .calls_by_symbol
1047                    .get(symbol)
1048                    .cloned()
1049                    .unwrap_or_default(),
1050                meta.0,
1051                meta.1,
1052            )
1053        };
1054
1055        // Build children
1056        let mut children = Vec::new();
1057        let mut depth_limited = false;
1058        let mut truncated = 0;
1059
1060        if current_depth < max_depth {
1061            for call_site in &call_sites {
1062                let edge = self.resolve_cross_file_edge(
1063                    &call_site.full_callee,
1064                    &call_site.callee_name,
1065                    &canon,
1066                    &import_block,
1067                );
1068
1069                match edge {
1070                    EdgeResolution::Resolved {
1071                        file: ref target_file,
1072                        ref symbol,
1073                    } => {
1074                        match self.forward_tree_inner(
1075                            target_file,
1076                            symbol,
1077                            max_depth,
1078                            current_depth + 1,
1079                            visited,
1080                        ) {
1081                            Ok(child) => {
1082                                depth_limited |= child.depth_limited;
1083                                truncated += child.truncated;
1084                                children.push(child);
1085                            }
1086                            Err(_) => {
1087                                // Target file can't be parsed — mark as unresolved leaf
1088                                children.push(CallTreeNode {
1089                                    name: call_site.callee_name.clone(),
1090                                    file: self.relative_path(target_file),
1091                                    line: call_site.line,
1092                                    signature: None,
1093                                    resolved: false,
1094                                    children: vec![],
1095                                    depth_limited: false,
1096                                    truncated: 0,
1097                                });
1098                            }
1099                        }
1100                    }
1101                    EdgeResolution::Unresolved { callee_name } => {
1102                        if let Some(local_child) = self.resolve_local_call_tree_child(
1103                            &canon,
1104                            symbol,
1105                            call_site,
1106                            &callee_name,
1107                            max_depth,
1108                            current_depth,
1109                            visited,
1110                        )? {
1111                            depth_limited |= local_child.depth_limited;
1112                            truncated += local_child.truncated;
1113                            children.push(local_child);
1114                            continue;
1115                        }
1116                        children.push(CallTreeNode {
1117                            name: callee_name,
1118                            file: self.relative_path(&canon),
1119                            line: call_site.line,
1120                            signature: None,
1121                            resolved: false,
1122                            children: vec![],
1123                            depth_limited: false,
1124                            truncated: 0,
1125                        });
1126                    }
1127                }
1128            }
1129        } else if !call_sites.is_empty() {
1130            depth_limited = true;
1131            truncated = call_sites.len();
1132        }
1133
1134        visited.remove(&visit_key);
1135
1136        Ok(CallTreeNode {
1137            name: symbol.to_string(),
1138            file: self.relative_path(&canon),
1139            line: sym_line,
1140            signature: sym_signature,
1141            resolved: true,
1142            children,
1143            depth_limited,
1144            truncated,
1145        })
1146    }
1147
1148    fn resolve_local_call_tree_child(
1149        &mut self,
1150        canon: &Path,
1151        current_symbol: &str,
1152        call_site: &CallSite,
1153        callee_name: &str,
1154        max_depth: usize,
1155        current_depth: usize,
1156        visited: &mut HashSet<(PathBuf, String)>,
1157    ) -> Result<Option<CallTreeNode>, AftError> {
1158        if !is_bare_callee(&call_site.full_callee, callee_name) {
1159            return Ok(None);
1160        }
1161
1162        let target_symbol = match self
1163            .lookup_file_data(canon)
1164            .and_then(|data| resolve_symbol_query_in_data(data, canon, callee_name).ok())
1165        {
1166            Some(symbol) => symbol,
1167            None => return Ok(None),
1168        };
1169
1170        if target_symbol == current_symbol {
1171            return Ok(None);
1172        }
1173
1174        match self.forward_tree_inner(canon, &target_symbol, max_depth, current_depth + 1, visited)
1175        {
1176            Ok(child) => Ok(Some(child)),
1177            Err(_) => Ok(Some(CallTreeNode {
1178                name: target_symbol,
1179                file: self.relative_path(canon),
1180                line: call_site.line,
1181                signature: None,
1182                resolved: false,
1183                children: vec![],
1184                depth_limited: false,
1185                truncated: 0,
1186            })),
1187        }
1188    }
1189
1190    /// Get all project files (lazily discovered).
1191    pub fn project_files(&mut self) -> &[PathBuf] {
1192        if self.project_files.is_none() {
1193            let project_root = self.project_root.clone();
1194            self.project_files = Some(walk_project_files(&project_root).collect());
1195        }
1196        self.project_files.as_deref().unwrap_or(&[])
1197    }
1198
1199    /// Get the total number of project source files.
1200    ///
1201    /// Triggers project file discovery on first access and returns the cached
1202    /// count thereafter. Prefer [`project_file_count_bounded`] when the caller
1203    /// only needs to know whether a threshold is exceeded.
1204    pub fn project_file_count(&mut self) -> usize {
1205        self.project_files().len()
1206    }
1207
1208    /// Count project source files, stopping after `limit + 1` so huge roots
1209    /// do not pay for a full walk or allocate a giant vector.
1210    ///
1211    /// Returns the real count when ≤ `limit`, or `limit + 1` when exceeded.
1212    /// Uses the cached `project_files` vec when it already exists (e.g. a
1213    /// previous call-graph op succeeded at this cap), otherwise short-circuits
1214    /// the underlying `ignore::Walk` iterator via `.take(limit + 1)`.
1215    ///
1216    /// CRITICAL: This method must NOT populate `self.project_files`. The whole
1217    /// point is to reject oversized roots before the full walk-and-collect runs.
1218    pub fn project_file_count_bounded(&self, limit: usize) -> usize {
1219        if let Some(files) = self.project_files.as_deref() {
1220            return files.len();
1221        }
1222        walk_project_files(&self.project_root)
1223            .take(limit.saturating_add(1))
1224            .count()
1225    }
1226
1227    /// Build call data for all project files, failing fast when the configured
1228    /// source-file cap is exceeded. Parses uncached files in a bounded parallel
1229    /// pool and caches the results for legacy in-memory callgraph operations.
1230    fn ensure_project_files_built(&mut self, max_files: usize) -> Result<(), AftError> {
1231        // Bounded count first — never populate project_files on oversized roots.
1232        // `walk_project_files(...).take(max_files + 1)` is lazy (Walk is an
1233        // iterator), so this costs at most (max_files + 1) directory entries
1234        // worth of work, not a full O(N) walk of the whole tree.
1235        let count = self.project_file_count_bounded(max_files);
1236        if count > max_files {
1237            return Err(AftError::ProjectTooLarge {
1238                count,
1239                max: max_files,
1240            });
1241        }
1242
1243        // TODO(v0.16): rust-side deadline for graceful timeout recovery
1244        // (unbounded walks remain a soft cliff for users who raise the cap).
1245        // Discover all project files first.
1246        let all_files = self.project_files().to_vec();
1247
1248        // Build file data for all project files.
1249        let uncached_files: Vec<PathBuf> = all_files
1250            .iter()
1251            .filter(|f| self.lookup_file_data(f).is_none())
1252            .cloned()
1253            .collect();
1254
1255        // Parsing every uncached source file is the dominant cost of a cold
1256        // call-graph query on a large repo. Log it so a slow (near-timeout)
1257        // first call is attributable to parse work rather than appearing as an
1258        // opaque bridge hang. Cheap no-op when nothing is uncached (warm cache).
1259        if !uncached_files.is_empty() {
1260            let started = Instant::now();
1261            // Parse on a BOUNDED pool (half the cores, cap 8), not the global
1262            // rayon pool. A cold `callers`/`impact`/`trace`/dead_code query runs
1263            // this parse, and on the global all-cores pool it pins every core and
1264            // starves the single-threaded bridge (the 800% spike). Half-cores
1265            // matches the store cold-build and inspect dispatch pools. 8MB worker
1266            // stacks match the main thread (tree-sitter AST walks are deep).
1267            let pool = callgraph_parse_pool();
1268            let computed: Vec<(PathBuf, FileCallData)> = pool.install(|| {
1269                uncached_files
1270                    .par_iter()
1271                    .filter_map(|f| build_file_data(f).ok().map(|data| (f.clone(), data)))
1272                    .collect()
1273            });
1274
1275            let parsed = computed.len();
1276            for (file, data) in computed {
1277                self.data.insert(file, data);
1278            }
1279            slog_info!(
1280                "perf callgraph: parsed {} uncached files ({} total project files) in {}ms (bounded {}-thread pool)",
1281                parsed,
1282                all_files.len(),
1283                started.elapsed().as_millis(),
1284                pool.current_num_threads(),
1285            );
1286        }
1287
1288        Ok(())
1289    }
1290
1291    /// Build the reverse index by scanning all project files.
1292    ///
1293    /// For each file, builds the call data (if not cached), then for each
1294    /// (symbol, call_sites) pair, resolves cross-file edges and inserts
1295    /// into the reverse map: `(target_file, target_symbol) → Vec<CallerSite>`.
1296    fn build_reverse_index(&mut self, max_files: usize) -> Result<(), AftError> {
1297        self.ensure_project_files_built(max_files)?;
1298        let all_files = self.project_files().to_vec();
1299
1300        // Cross-file edge resolution is the second dominant cost (after parsing)
1301        // of a cold callers/impact query; time it so a slow first call is fully
1302        // attributable across the two phases.
1303        let reverse_started = Instant::now();
1304
1305        // Now build the reverse map
1306        let mut reverse: ReverseIndex = HashMap::new();
1307
1308        for caller_file in &all_files {
1309            // Canonicalize the caller file path for consistent lookups (memoized
1310            // so repeated reverse-index builds don't re-issue the realpath
1311            // syscall for every project file).
1312            let canon_caller = self.canonicalize_cached(caller_file);
1313            let file_data = match self
1314                .data
1315                .get(caller_file)
1316                .or_else(|| self.data.get(canon_caller.as_ref()))
1317            {
1318                Some(d) => d,
1319                None => continue,
1320            };
1321
1322            for (symbol_name, call_sites) in &file_data.calls_by_symbol {
1323                let caller_symbol: SharedStr = Arc::from(symbol_name.as_str());
1324
1325                for call_site in call_sites {
1326                    let edge = Self::resolve_cross_file_edge_with_exports(
1327                        &call_site.full_callee,
1328                        &call_site.callee_name,
1329                        canon_caller.as_ref(),
1330                        &file_data.import_block,
1331                        |path, symbol_name| self.file_exports_symbol_cached(path, symbol_name),
1332                        |path| self.file_default_export_symbol_cached(path),
1333                    );
1334
1335                    let (target_file, target_symbol, resolved) = match edge {
1336                        EdgeResolution::Resolved { file, symbol } => (file, symbol, true),
1337                        EdgeResolution::Unresolved { callee_name } => {
1338                            if !is_bare_callee(&call_site.full_callee, &callee_name) {
1339                                continue;
1340                            }
1341
1342                            let Ok(target_symbol) = resolve_symbol_query_in_data(
1343                                file_data,
1344                                canon_caller.as_ref(),
1345                                &callee_name,
1346                            ) else {
1347                                continue;
1348                            };
1349
1350                            (canon_caller.as_ref().clone(), target_symbol, false)
1351                        }
1352                    };
1353
1354                    if target_file == *canon_caller.as_ref() && target_symbol == *symbol_name {
1355                        continue;
1356                    }
1357
1358                    reverse
1359                        .entry(target_file)
1360                        .or_default()
1361                        .entry(target_symbol)
1362                        .or_default()
1363                        .push(IndexedCallerSite {
1364                            caller_file: Arc::clone(&canon_caller),
1365                            caller_symbol: Arc::clone(&caller_symbol),
1366                            line: call_site.line,
1367                            col: 0,
1368                            resolved,
1369                        });
1370                }
1371            }
1372        }
1373
1374        let edges: usize = reverse
1375            .values()
1376            .map(|m| m.values().map(Vec::len).sum::<usize>())
1377            .sum();
1378        self.reverse_index = Some(reverse);
1379        slog_debug!(
1380            "callgraph: built reverse index ({} edges over {} files) in {}ms",
1381            edges,
1382            all_files.len(),
1383            reverse_started.elapsed().as_millis()
1384        );
1385        Ok(())
1386    }
1387
1388    fn reverse_sites(&self, file: &Path, symbol: &str) -> Option<&[IndexedCallerSite]> {
1389        self.reverse_index
1390            .as_ref()?
1391            .get(file)?
1392            .get(symbol)
1393            .map(Vec::as_slice)
1394    }
1395
1396    /// Get callers of a symbol in a file, grouped by calling file.
1397    ///
1398    /// Builds the reverse index on first call (scans all project files).
1399    /// Supports recursive depth expansion: depth=1 returns direct callers,
1400    /// depth=2 returns callers-of-callers, etc. depth=0 is treated as 1.
1401    pub fn callers_of(
1402        &mut self,
1403        file: &Path,
1404        symbol: &str,
1405        depth: usize,
1406        max_files: usize,
1407    ) -> Result<CallersResult, AftError> {
1408        let canon = self.canonicalize(file)?;
1409
1410        // Ensure file is built (may already be cached) and resolve scoped identity.
1411        let resolved_symbol = {
1412            let file_data = self.build_file(&canon)?;
1413            resolve_symbol_query_in_data(file_data, &canon, symbol)?
1414        };
1415
1416        // Build the reverse index if not cached
1417        if self.reverse_index.is_none() {
1418            self.build_reverse_index(max_files)?;
1419        }
1420
1421        let scanned_files = self.project_files.as_ref().map(|f| f.len()).unwrap_or(0);
1422        let effective_depth = if depth == 0 { 1 } else { depth };
1423
1424        let mut visited = HashSet::new();
1425        let mut all_sites: Vec<CallerSite> = Vec::new();
1426        let mut depth_limited = false;
1427        let mut truncated = 0;
1428        self.collect_callers_recursive(
1429            &canon,
1430            &resolved_symbol,
1431            effective_depth,
1432            0,
1433            &mut visited,
1434            &mut all_sites,
1435            &mut depth_limited,
1436            &mut truncated,
1437        );
1438
1439        // Group by file
1440
1441        let mut groups_map: HashMap<PathBuf, Vec<CallerEntry>> = HashMap::new();
1442        let total_callers = all_sites.len();
1443        for site in all_sites {
1444            let caller_file: PathBuf = site.caller_file;
1445            let caller_symbol: String = site.caller_symbol;
1446            let line = site.line;
1447            let entry = CallerEntry {
1448                symbol: caller_symbol,
1449                line,
1450            };
1451
1452            if let Some(entries) = groups_map.get_mut(&caller_file) {
1453                entries.push(entry);
1454            } else {
1455                groups_map.insert(caller_file, vec![entry]);
1456            }
1457        }
1458
1459        let mut callers: Vec<CallerGroup> = groups_map
1460            .into_iter()
1461            .map(|(file_path, entries)| CallerGroup {
1462                file: self.relative_path(&file_path),
1463                callers: entries,
1464            })
1465            .collect();
1466
1467        // Sort groups by file path for deterministic output
1468        callers.sort_by(|a, b| a.file.cmp(&b.file));
1469
1470        Ok(CallersResult {
1471            symbol: resolved_symbol,
1472            file: self.relative_path(&canon),
1473            callers,
1474            total_callers,
1475            scanned_files,
1476            depth_limited,
1477            truncated,
1478        })
1479    }
1480
1481    /// Trace backward from a symbol to all entry points.
1482    ///
1483    /// Returns complete paths (top-down: entry point first, target last).
1484    /// Uses BFS backward through the reverse index, with per-path cycle
1485    /// detection and depth limiting.
1486    pub fn trace_to(
1487        &mut self,
1488        file: &Path,
1489        symbol: &str,
1490        max_depth: usize,
1491        max_files: usize,
1492    ) -> Result<TraceToResult, AftError> {
1493        let canon = self.canonicalize(file)?;
1494
1495        // Ensure file is built and resolve scoped identity.
1496        let resolved_symbol = {
1497            let file_data = self.build_file(&canon)?;
1498            resolve_symbol_query_in_data(file_data, &canon, symbol)?
1499        };
1500
1501        // Build the reverse index if not cached
1502        if self.reverse_index.is_none() {
1503            self.build_reverse_index(max_files)?;
1504        }
1505
1506        let target_rel = self.relative_path(&canon);
1507        let effective_max = if max_depth == 0 { 10 } else { max_depth };
1508        if self.reverse_index.is_none() {
1509            return Err(AftError::ParseError {
1510                message: format!(
1511                    "reverse index unavailable after building callers for {}",
1512                    canon.display()
1513                ),
1514            });
1515        }
1516
1517        // Get line/signature for the target symbol
1518        let (target_line, target_sig) = self
1519            .lookup_file_data(&canon)
1520            .map(|data| get_symbol_meta_from_data(data, &resolved_symbol))
1521            .unwrap_or_else(|| get_symbol_meta(&canon, &resolved_symbol));
1522
1523        // Check if target itself is an entry point
1524        let target_is_entry = self
1525            .lookup_file_data(&canon)
1526            .and_then(|fd| {
1527                let meta = fd.symbol_metadata.get(&resolved_symbol)?;
1528                Some(is_entry_point(
1529                    &resolved_symbol,
1530                    &meta.kind,
1531                    meta.exported,
1532                    fd.lang,
1533                ))
1534            })
1535            .unwrap_or(false);
1536
1537        // BFS state: each item is a partial path (bottom-up, will be reversed later)
1538        // Each path element: (canonicalized file, symbol name, line, signature)
1539        type PathElem = (SharedPath, SharedStr, u32, Option<String>);
1540        let mut complete_paths: Vec<Vec<PathElem>> = Vec::new();
1541        let mut max_depth_reached = false;
1542        let mut truncated_paths: usize = 0;
1543
1544        // Initial path starts at the target
1545        let initial: Vec<PathElem> = vec![(
1546            Arc::new(canon.clone()),
1547            Arc::from(resolved_symbol.as_str()),
1548            target_line,
1549            target_sig,
1550        )];
1551
1552        // If the target itself is an entry point, record it as a trivial path
1553        if target_is_entry {
1554            complete_paths.push(initial.clone());
1555        }
1556
1557        // Queue of (current_path, depth)
1558        let mut queue: Vec<(Vec<PathElem>, usize)> = vec![(initial, 0)];
1559
1560        while let Some((path, depth)) = queue.pop() {
1561            if depth >= effective_max {
1562                max_depth_reached = true;
1563                continue;
1564            }
1565
1566            let Some((current_file, current_symbol, _, _)) = path.last() else {
1567                continue;
1568            };
1569
1570            // Look up callers in reverse index
1571            let callers = match self.reverse_sites(current_file.as_ref(), current_symbol.as_ref()) {
1572                Some(sites) => sites,
1573                None => {
1574                    // Dead end: no callers and not an entry point
1575                    // (if it were an entry point, we'd have recorded it already)
1576                    if path.len() > 1 {
1577                        // Only count as truncated if this isn't the target itself
1578                        // (the target with no callers is just "no paths found")
1579                        truncated_paths += 1;
1580                    }
1581                    continue;
1582                }
1583            };
1584
1585            let mut has_new_path = false;
1586            for site in callers {
1587                // Cycle detection: skip if this caller is already in the current path
1588                if path.iter().any(|(file_path, sym, _, _)| {
1589                    file_path.as_ref() == site.caller_file.as_ref()
1590                        && sym.as_ref() == site.caller_symbol.as_ref()
1591                }) {
1592                    continue;
1593                }
1594
1595                has_new_path = true;
1596
1597                // Get caller's metadata
1598                let (caller_line, caller_sig) = self
1599                    .lookup_file_data(site.caller_file.as_ref())
1600                    .map(|data| get_symbol_meta_from_data(data, site.caller_symbol.as_ref()))
1601                    .unwrap_or_else(|| {
1602                        get_symbol_meta(site.caller_file.as_ref(), site.caller_symbol.as_ref())
1603                    });
1604
1605                let mut new_path = path.clone();
1606                new_path.push((
1607                    Arc::clone(&site.caller_file),
1608                    Arc::clone(&site.caller_symbol),
1609                    caller_line,
1610                    caller_sig,
1611                ));
1612
1613                // Check if this caller is an entry point
1614                // Try both canonical and non-canonical keys (build_reverse_index
1615                // may have stored data under the raw walker path)
1616                let caller_is_entry = self
1617                    .lookup_file_data(site.caller_file.as_ref())
1618                    .and_then(|fd| {
1619                        let meta = fd.symbol_metadata.get(site.caller_symbol.as_ref())?;
1620                        Some(is_entry_point(
1621                            site.caller_symbol.as_ref(),
1622                            &meta.kind,
1623                            meta.exported,
1624                            fd.lang,
1625                        ))
1626                    })
1627                    .unwrap_or(false);
1628
1629                if caller_is_entry {
1630                    complete_paths.push(new_path.clone());
1631                }
1632                // Always continue searching backward — there may be longer
1633                // paths through other entry points beyond this one
1634                queue.push((new_path, depth + 1));
1635            }
1636
1637            // If we had callers but none were new (all cycles), count as truncated
1638            if !has_new_path && path.len() > 1 {
1639                truncated_paths += 1;
1640            }
1641        }
1642
1643        // Reverse each path so it reads top-down (entry point → ... → target)
1644        // and convert to TraceHop/TracePath
1645        let mut paths: Vec<TracePath> = complete_paths
1646            .into_iter()
1647            .map(|mut elems| {
1648                elems.reverse();
1649                let hops: Vec<TraceHop> = elems
1650                    .iter()
1651                    .enumerate()
1652                    .map(|(i, (file_path, sym, line, sig))| {
1653                        let is_ep = if i == 0 {
1654                            // First hop (after reverse) is the entry point
1655                            self.lookup_file_data(file_path.as_ref())
1656                                .and_then(|fd| {
1657                                    let meta = fd.symbol_metadata.get(sym.as_ref())?;
1658                                    Some(is_entry_point(
1659                                        sym.as_ref(),
1660                                        &meta.kind,
1661                                        meta.exported,
1662                                        fd.lang,
1663                                    ))
1664                                })
1665                                .unwrap_or(false)
1666                        } else {
1667                            false
1668                        };
1669                        TraceHop {
1670                            symbol: sym.to_string(),
1671                            file: self.relative_path(file_path.as_ref()),
1672                            line: *line,
1673                            signature: sig.clone(),
1674                            is_entry_point: is_ep,
1675                        }
1676                    })
1677                    .collect();
1678                TracePath { hops }
1679            })
1680            .collect();
1681
1682        // Sort paths for deterministic output (by entry point name, then path length)
1683        paths.sort_by(|a, b| {
1684            let a_entry = a.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1685            let b_entry = b.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1686            a_entry.cmp(b_entry).then(a.hops.len().cmp(&b.hops.len()))
1687        });
1688
1689        // Count distinct entry points by identity, not just display name.
1690        let mut entry_points: HashSet<(String, String)> = HashSet::new();
1691        for p in &paths {
1692            if let Some(first) = p.hops.first() {
1693                if first.is_entry_point {
1694                    entry_points.insert((first.file.clone(), first.symbol.clone()));
1695                }
1696            }
1697        }
1698
1699        let total_paths = paths.len();
1700        let entry_points_found = entry_points.len();
1701
1702        Ok(TraceToResult {
1703            target_symbol: resolved_symbol,
1704            target_file: target_rel,
1705            paths,
1706            total_paths,
1707            entry_points_found,
1708            max_depth_reached,
1709            truncated_paths,
1710        })
1711    }
1712
1713    /// Find all files that define a symbol matching a `trace_to_symbol` target query.
1714    ///
1715    /// The result is de-duplicated by file because `toFile` is only required
1716    /// when a target symbol name exists in multiple files.
1717    pub fn trace_to_symbol_candidates(
1718        &mut self,
1719        to_symbol: &str,
1720        max_files: usize,
1721    ) -> Result<Vec<TraceToSymbolCandidate>, AftError> {
1722        self.ensure_project_files_built(max_files)?;
1723
1724        let mut candidates_by_file: HashMap<PathBuf, u32> = HashMap::new();
1725        let all_files = self.project_files().to_vec();
1726
1727        for file in all_files {
1728            let canon = self.canonicalize(&file)?;
1729            let Some(file_data) = self
1730                .lookup_file_data(&canon)
1731                .or_else(|| self.lookup_file_data(&file))
1732            else {
1733                continue;
1734            };
1735
1736            let symbol_candidates = symbol_query_candidates(file_data, to_symbol);
1737            if symbol_candidates.is_empty() {
1738                continue;
1739            }
1740
1741            let line = symbol_candidates
1742                .iter()
1743                .filter_map(|symbol| file_data.symbol_metadata.get(symbol).map(|meta| meta.line))
1744                .min()
1745                .unwrap_or(1);
1746
1747            candidates_by_file
1748                .entry(canon)
1749                .and_modify(|existing| *existing = (*existing).min(line))
1750                .or_insert(line);
1751        }
1752
1753        let mut candidates: Vec<TraceToSymbolCandidate> = candidates_by_file
1754            .into_iter()
1755            .map(|(file, line)| TraceToSymbolCandidate {
1756                file: self.relative_path(&file),
1757                line,
1758            })
1759            .collect();
1760        candidates.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
1761        Ok(candidates)
1762    }
1763
1764    /// Find the shortest forward call path from one symbol to another symbol.
1765    ///
1766    /// Performs breadth-first traversal over resolved call edges. A global
1767    /// `(file, symbol)` visited set keeps cycles finite while preserving BFS's
1768    /// shortest-path guarantee.
1769    pub fn trace_to_symbol(
1770        &mut self,
1771        file: &Path,
1772        symbol: &str,
1773        to_symbol: &str,
1774        to_file: Option<&Path>,
1775        max_depth: usize,
1776        max_files: usize,
1777    ) -> Result<TraceToSymbolResult, AftError> {
1778        let canon = self.canonicalize(file)?;
1779
1780        // Ensure the origin file is built and resolve scoped identity.
1781        let resolved_symbol = {
1782            let file_data = self.build_file(&canon)?;
1783            resolve_symbol_query_in_data(file_data, &canon, symbol)?
1784        };
1785
1786        self.ensure_project_files_built(max_files)?;
1787
1788        let target_file = to_file.map(|path| self.canonicalize(path)).transpose()?;
1789        let effective_max = if max_depth == 0 {
1790            10
1791        } else {
1792            max_depth.min(16)
1793        };
1794
1795        let start_hop = self.trace_to_symbol_hop(&canon, &resolved_symbol);
1796        if Self::trace_to_symbol_matches_target(&canon, &resolved_symbol, to_symbol, &target_file) {
1797            return Ok(TraceToSymbolResult {
1798                path: Some(vec![start_hop]),
1799                complete: true,
1800                reason: None,
1801            });
1802        }
1803
1804        let mut queue: VecDeque<(PathBuf, String, Vec<TraceToSymbolHop>, usize)> = VecDeque::new();
1805        queue.push_back((canon.clone(), resolved_symbol.clone(), vec![start_hop], 0));
1806
1807        let mut visited: HashSet<(PathBuf, String)> = HashSet::new();
1808        visited.insert((canon, resolved_symbol));
1809        let mut max_depth_exhausted = false;
1810
1811        while let Some((current_file, current_symbol, path, depth)) = queue.pop_front() {
1812            let callees = self.forward_resolved_callees(&current_file, &current_symbol)?;
1813
1814            if depth >= effective_max {
1815                if callees
1816                    .iter()
1817                    .any(|(file, symbol)| !visited.contains(&(file.clone(), symbol.clone())))
1818                {
1819                    max_depth_exhausted = true;
1820                }
1821                continue;
1822            }
1823
1824            for (callee_file, callee_symbol) in callees {
1825                let visit_key = (callee_file.clone(), callee_symbol.clone());
1826                if !visited.insert(visit_key) {
1827                    continue;
1828                }
1829
1830                let mut next_path = path.clone();
1831                next_path.push(self.trace_to_symbol_hop(&callee_file, &callee_symbol));
1832
1833                if Self::trace_to_symbol_matches_target(
1834                    &callee_file,
1835                    &callee_symbol,
1836                    to_symbol,
1837                    &target_file,
1838                ) {
1839                    return Ok(TraceToSymbolResult {
1840                        path: Some(next_path),
1841                        complete: true,
1842                        reason: None,
1843                    });
1844                }
1845
1846                queue.push_back((callee_file, callee_symbol, next_path, depth + 1));
1847            }
1848        }
1849
1850        if max_depth_exhausted {
1851            Ok(TraceToSymbolResult {
1852                path: None,
1853                complete: false,
1854                reason: Some("max_depth_exhausted".to_string()),
1855            })
1856        } else {
1857            Ok(TraceToSymbolResult {
1858                path: None,
1859                complete: true,
1860                reason: Some("no_path_found".to_string()),
1861            })
1862        }
1863    }
1864
1865    fn trace_to_symbol_matches_target(
1866        file: &Path,
1867        symbol: &str,
1868        to_symbol: &str,
1869        to_file: &Option<PathBuf>,
1870    ) -> bool {
1871        if !symbol_query_matches(symbol, to_symbol) {
1872            return false;
1873        }
1874
1875        if let Some(target_file) = to_file {
1876            file == target_file
1877        } else {
1878            true
1879        }
1880    }
1881
1882    fn trace_to_symbol_hop(&self, file: &Path, symbol: &str) -> TraceToSymbolHop {
1883        let (line, _) = self
1884            .lookup_file_data(file)
1885            .map(|data| get_symbol_meta_from_data(data, symbol))
1886            .unwrap_or_else(|| get_symbol_meta(file, symbol));
1887
1888        TraceToSymbolHop {
1889            symbol: symbol.to_string(),
1890            file: self.relative_path(file),
1891            line,
1892        }
1893    }
1894
1895    fn forward_resolved_callees(
1896        &mut self,
1897        file: &Path,
1898        symbol: &str,
1899    ) -> Result<Vec<(PathBuf, String)>, AftError> {
1900        let canon = self.canonicalize(file)?;
1901        let (import_block, call_sites) = {
1902            let file_data = self.build_file(&canon)?;
1903            (
1904                file_data.import_block.clone(),
1905                file_data
1906                    .calls_by_symbol
1907                    .get(symbol)
1908                    .cloned()
1909                    .unwrap_or_default(),
1910            )
1911        };
1912
1913        let mut callees = Vec::new();
1914        for call_site in call_sites {
1915            let edge = self.resolve_cross_file_edge(
1916                &call_site.full_callee,
1917                &call_site.callee_name,
1918                &canon,
1919                &import_block,
1920            );
1921
1922            match edge {
1923                EdgeResolution::Resolved {
1924                    file: target_file,
1925                    symbol: target_symbol,
1926                } => {
1927                    let target_canon = self.canonicalize(&target_file)?;
1928                    if self.build_file(&target_canon).is_err() {
1929                        continue;
1930                    }
1931
1932                    let resolved_target_symbol = self
1933                        .lookup_file_data(&target_canon)
1934                        .and_then(|data| {
1935                            resolve_symbol_query_in_data(data, &target_canon, &target_symbol).ok()
1936                        })
1937                        .unwrap_or(target_symbol);
1938
1939                    callees.push((target_canon, resolved_target_symbol));
1940                }
1941                EdgeResolution::Unresolved { callee_name } => {
1942                    if !is_bare_callee(&call_site.full_callee, &callee_name) {
1943                        continue;
1944                    }
1945
1946                    let local_symbol = self.lookup_file_data(&canon).and_then(|data| {
1947                        resolve_symbol_query_in_data(data, &canon, &callee_name).ok()
1948                    });
1949
1950                    if let Some(local_symbol) = local_symbol {
1951                        callees.push((canon.clone(), local_symbol));
1952                    }
1953                }
1954            }
1955        }
1956
1957        Ok(callees)
1958    }
1959
1960    /// Impact analysis: enriched callers query.
1961    ///
1962    /// Returns all call sites affected by a change to the given symbol,
1963    /// annotated with each caller's signature, entry point status, the
1964    /// source line at the call site, and extracted parameter names.
1965    pub fn impact(
1966        &mut self,
1967        file: &Path,
1968        symbol: &str,
1969        depth: usize,
1970        max_files: usize,
1971    ) -> Result<ImpactResult, AftError> {
1972        let canon = self.canonicalize(file)?;
1973
1974        // Ensure file is built and resolve scoped identity.
1975        let resolved_symbol = {
1976            let file_data = self.build_file(&canon)?;
1977            resolve_symbol_query_in_data(file_data, &canon, symbol)?
1978        };
1979
1980        // Build the reverse index if not cached
1981        if self.reverse_index.is_none() {
1982            self.build_reverse_index(max_files)?;
1983        }
1984
1985        let effective_depth = if depth == 0 { 1 } else { depth };
1986
1987        // Get the target symbol's own metadata
1988        let (target_signature, target_parameters, target_lang) = {
1989            let file_data = match self.data.get(&canon) {
1990                Some(d) => d,
1991                None => {
1992                    return Err(AftError::InvalidRequest {
1993                        message: "file data missing after build".to_string(),
1994                    })
1995                }
1996            };
1997            let meta = file_data.symbol_metadata.get(&resolved_symbol);
1998            let sig = meta.and_then(|m| m.signature.clone());
1999            let lang = file_data.lang;
2000            let params = sig
2001                .as_deref()
2002                .map(|s| extract_parameters(s, lang))
2003                .unwrap_or_default();
2004            (sig, params, lang)
2005        };
2006
2007        // Collect all caller sites (transitive)
2008        let mut visited = HashSet::new();
2009        let mut all_sites: Vec<CallerSite> = Vec::new();
2010        let mut depth_limited = false;
2011        let mut truncated = 0;
2012        self.collect_callers_recursive(
2013            &canon,
2014            &resolved_symbol,
2015            effective_depth,
2016            0,
2017            &mut visited,
2018            &mut all_sites,
2019            &mut depth_limited,
2020            &mut truncated,
2021        );
2022
2023        // Deduplicate sites by (file, symbol, line)
2024        let mut seen: HashSet<(PathBuf, String, u32)> = HashSet::new();
2025        all_sites.retain(|site| {
2026            seen.insert((
2027                site.caller_file.clone(),
2028                site.caller_symbol.clone(),
2029                site.line,
2030            ))
2031        });
2032
2033        // Enrich each caller site
2034        let mut callers = Vec::new();
2035        let mut affected_file_set = HashSet::new();
2036
2037        for site in &all_sites {
2038            // Build the caller's file to get metadata
2039            if let Err(e) = self.build_file(site.caller_file.as_path()) {
2040                log::debug!(
2041                    "callgraph: skipping caller file {}: {}",
2042                    site.caller_file.display(),
2043                    e
2044                );
2045            }
2046
2047            let (sig, is_ep, params, _lang) = {
2048                if let Some(fd) = self.lookup_file_data(site.caller_file.as_path()) {
2049                    let meta = fd.symbol_metadata.get(&site.caller_symbol);
2050                    let sig = meta.and_then(|m| m.signature.clone());
2051                    let kind = meta.map(|m| m.kind.clone()).unwrap_or(SymbolKind::Function);
2052                    let exported = meta.map(|m| m.exported).unwrap_or(false);
2053                    let is_ep = is_entry_point(&site.caller_symbol, &kind, exported, fd.lang);
2054                    let lang = fd.lang;
2055                    let params = sig
2056                        .as_deref()
2057                        .map(|s| extract_parameters(s, lang))
2058                        .unwrap_or_default();
2059                    (sig, is_ep, params, lang)
2060                } else {
2061                    (None, false, Vec::new(), target_lang)
2062                }
2063            };
2064
2065            // Read the source line at the call site
2066            let call_expression = self.read_source_line(site.caller_file.as_path(), site.line);
2067
2068            let rel_file = self.relative_path(site.caller_file.as_path());
2069            affected_file_set.insert(rel_file.clone());
2070
2071            callers.push(ImpactCaller {
2072                caller_symbol: site.caller_symbol.clone(),
2073                caller_file: rel_file,
2074                line: site.line,
2075                signature: sig,
2076                is_entry_point: is_ep,
2077                call_expression,
2078                parameters: params,
2079            });
2080        }
2081
2082        // Sort callers by file then line for deterministic output
2083        callers.sort_by(|a, b| a.caller_file.cmp(&b.caller_file).then(a.line.cmp(&b.line)));
2084
2085        let total_affected = callers.len();
2086        let affected_files = affected_file_set.len();
2087
2088        Ok(ImpactResult {
2089            symbol: resolved_symbol,
2090            file: self.relative_path(&canon),
2091            signature: target_signature,
2092            parameters: target_parameters,
2093            total_affected,
2094            affected_files,
2095            callers,
2096            depth_limited,
2097            truncated,
2098        })
2099    }
2100
2101    /// Trace how an expression flows through variable assignments within a
2102    /// function body and across function boundaries via argument-to-parameter
2103    /// matching.
2104    ///
2105    /// Algorithm:
2106    /// 1. Parse the function body, find the expression text.
2107    /// 2. Walk AST for assignments that reference the tracked name.
2108    /// 3. When the tracked name appears as a call argument, resolve the callee,
2109    ///    match argument position to parameter name, recurse.
2110    /// 4. Destructuring, spread, and unresolved calls produce approximate hops.
2111    pub fn trace_data(
2112        &mut self,
2113        file: &Path,
2114        symbol: &str,
2115        expression: &str,
2116        max_depth: usize,
2117        max_files: usize,
2118    ) -> Result<TraceDataResult, AftError> {
2119        let canon = self.canonicalize(file)?;
2120        let rel_file = self.relative_path(&canon);
2121
2122        // Ensure file data is built and resolve scoped identity.
2123        let resolved_symbol = {
2124            let file_data = self.build_file(&canon)?;
2125            resolve_symbol_query_in_data(file_data, &canon, symbol)?
2126        };
2127
2128        // Bounded count: short-circuits at `max_files + 1` so oversized roots
2129        // reject in microseconds instead of paying the full walk/collect cost.
2130        // Matches the guard used by build_reverse_index / callers_of / trace_to / impact.
2131        let count = self.project_file_count_bounded(max_files);
2132        if count > max_files {
2133            return Err(AftError::ProjectTooLarge {
2134                count,
2135                max: max_files,
2136            });
2137        }
2138
2139        let mut hops = Vec::new();
2140        let mut depth_limited = false;
2141
2142        self.trace_data_inner(
2143            &canon,
2144            &resolved_symbol,
2145            expression,
2146            max_depth,
2147            0,
2148            &mut hops,
2149            &mut depth_limited,
2150            &mut HashSet::new(),
2151        );
2152
2153        Ok(TraceDataResult {
2154            expression: expression.to_string(),
2155            origin_file: rel_file,
2156            origin_symbol: resolved_symbol,
2157            hops,
2158            depth_limited,
2159        })
2160    }
2161
2162    /// Inner recursive data flow tracking.
2163    fn trace_data_inner(
2164        &mut self,
2165        file: &Path,
2166        symbol: &str,
2167        tracking_name: &str,
2168        max_depth: usize,
2169        current_depth: usize,
2170        hops: &mut Vec<DataFlowHop>,
2171        depth_limited: &mut bool,
2172        visited: &mut HashSet<(PathBuf, String, String)>,
2173    ) {
2174        let visit_key = (
2175            file.to_path_buf(),
2176            symbol.to_string(),
2177            tracking_name.to_string(),
2178        );
2179        if visited.contains(&visit_key) {
2180            return; // cycle
2181        }
2182        visited.insert(visit_key);
2183
2184        // Read and parse the file
2185        let source = match std::fs::read_to_string(file) {
2186            Ok(s) => s,
2187            Err(_) => return,
2188        };
2189
2190        let lang = match detect_language(file) {
2191            Some(l) => l,
2192            None => return,
2193        };
2194
2195        let grammar = grammar_for(lang);
2196        let mut parser = Parser::new();
2197        if parser.set_language(&grammar).is_err() {
2198            return;
2199        }
2200        let tree = match parser.parse(&source, None) {
2201            Some(t) => t,
2202            None => return,
2203        };
2204
2205        // Find the symbol's AST node range
2206        let symbols = match crate::parser::extract_symbols_from_tree(&source, &tree, lang) {
2207            Ok(symbols) => symbols,
2208            Err(_) => return,
2209        };
2210        let sym_info = match symbols
2211            .iter()
2212            .find(|s| symbol_identity(s) == symbol || s.name == symbol)
2213        {
2214            Some(s) => s,
2215            None => return,
2216        };
2217
2218        let body_start =
2219            line_col_to_byte(&source, sym_info.range.start_line, sym_info.range.start_col);
2220        let body_end = line_col_to_byte(&source, sym_info.range.end_line, sym_info.range.end_col);
2221
2222        let root = tree.root_node();
2223
2224        // Find the symbol's body node (the function/method definition node)
2225        let body_node = match find_node_covering_range(root, body_start, body_end) {
2226            Some(n) => n,
2227            None => return,
2228        };
2229
2230        // Track names through the body
2231        let mut tracked_names: Vec<String> = vec![tracking_name.to_string()];
2232        let rel_file = self.relative_path(file);
2233
2234        // Walk the body looking for assignments and calls
2235        self.walk_for_data_flow(
2236            body_node,
2237            &source,
2238            &mut tracked_names,
2239            file,
2240            symbol,
2241            &rel_file,
2242            lang,
2243            max_depth,
2244            current_depth,
2245            hops,
2246            depth_limited,
2247            visited,
2248        );
2249    }
2250
2251    /// Walk an AST subtree looking for assignments and call expressions that
2252    /// reference tracked names.
2253    #[allow(clippy::too_many_arguments)]
2254    fn walk_for_data_flow(
2255        &mut self,
2256        node: tree_sitter::Node,
2257        source: &str,
2258        tracked_names: &mut Vec<String>,
2259        file: &Path,
2260        symbol: &str,
2261        rel_file: &str,
2262        lang: LangId,
2263        max_depth: usize,
2264        current_depth: usize,
2265        hops: &mut Vec<DataFlowHop>,
2266        depth_limited: &mut bool,
2267        visited: &mut HashSet<(PathBuf, String, String)>,
2268    ) {
2269        let kind = node.kind();
2270
2271        // Check for variable declarations / assignments
2272        let is_var_decl = matches!(
2273            kind,
2274            "variable_declarator"
2275                | "assignment_expression"
2276                | "augmented_assignment_expression"
2277                | "assignment"
2278                | "let_declaration"
2279                | "short_var_declaration"
2280        );
2281
2282        if is_var_decl {
2283            if let Some((new_name, init_text, line, is_approx)) =
2284                self.extract_assignment_info(node, source, lang, tracked_names)
2285            {
2286                // The RHS references a tracked name — add assignment hop
2287                if !is_approx {
2288                    hops.push(DataFlowHop {
2289                        file: rel_file.to_string(),
2290                        symbol: symbol.to_string(),
2291                        variable: new_name.clone(),
2292                        line,
2293                        flow_type: "assignment".to_string(),
2294                        approximate: false,
2295                    });
2296                    tracked_names.push(new_name);
2297                } else {
2298                    // Destructuring or pattern — approximate
2299                    hops.push(DataFlowHop {
2300                        file: rel_file.to_string(),
2301                        symbol: symbol.to_string(),
2302                        variable: init_text,
2303                        line,
2304                        flow_type: "assignment".to_string(),
2305                        approximate: true,
2306                    });
2307                    // Don't track further through this branch
2308                    return;
2309                }
2310            }
2311        }
2312
2313        // Check for call expressions where tracked name is an argument
2314        if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
2315            self.check_call_for_data_flow(
2316                node,
2317                source,
2318                tracked_names,
2319                file,
2320                symbol,
2321                rel_file,
2322                lang,
2323                max_depth,
2324                current_depth,
2325                hops,
2326                depth_limited,
2327                visited,
2328            );
2329        }
2330
2331        // Recurse into children
2332        let mut cursor = node.walk();
2333        if cursor.goto_first_child() {
2334            loop {
2335                let child = cursor.node();
2336                // Don't re-process the current node type in recursion
2337                self.walk_for_data_flow(
2338                    child,
2339                    source,
2340                    tracked_names,
2341                    file,
2342                    symbol,
2343                    rel_file,
2344                    lang,
2345                    max_depth,
2346                    current_depth,
2347                    hops,
2348                    depth_limited,
2349                    visited,
2350                );
2351                if !cursor.goto_next_sibling() {
2352                    break;
2353                }
2354            }
2355        }
2356    }
2357
2358    /// Check if an assignment/declaration node assigns from a tracked name.
2359    /// Returns (new_name, init_text, line, is_approximate).
2360    fn extract_assignment_info(
2361        &self,
2362        node: tree_sitter::Node,
2363        source: &str,
2364        _lang: LangId,
2365        tracked_names: &[String],
2366    ) -> Option<(String, String, u32, bool)> {
2367        let kind = node.kind();
2368        let line = node.start_position().row as u32 + 1;
2369
2370        match kind {
2371            "variable_declarator" => {
2372                // TS/JS: const x = <expr>
2373                let name_node = node.child_by_field_name("name")?;
2374                let value_node = node.child_by_field_name("value")?;
2375                let name_text = node_text(name_node, source);
2376                let value_text = node_text(value_node, source);
2377
2378                // Check if name is a destructuring pattern
2379                if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
2380                    // Check if value references a tracked name
2381                    if tracked_names.iter().any(|t| value_text.contains(t)) {
2382                        return Some((name_text.clone(), name_text, line, true));
2383                    }
2384                    return None;
2385                }
2386
2387                // Check if value references any tracked name
2388                if tracked_names.iter().any(|t| {
2389                    value_text == *t
2390                        || value_text.starts_with(&format!("{}.", t))
2391                        || value_text.starts_with(&format!("{}[", t))
2392                }) {
2393                    return Some((name_text, value_text, line, false));
2394                }
2395                None
2396            }
2397            "assignment_expression" | "augmented_assignment_expression" => {
2398                // TS/JS: x = <expr>
2399                let left = node.child_by_field_name("left")?;
2400                let right = node.child_by_field_name("right")?;
2401                let left_text = node_text(left, source);
2402                let right_text = node_text(right, source);
2403
2404                if tracked_names.iter().any(|t| right_text == *t) {
2405                    return Some((left_text, right_text, line, false));
2406                }
2407                None
2408            }
2409            "assignment" => {
2410                // Python: x = <expr>
2411                let left = node.child_by_field_name("left")?;
2412                let right = node.child_by_field_name("right")?;
2413                let left_text = node_text(left, source);
2414                let right_text = node_text(right, source);
2415
2416                if tracked_names.iter().any(|t| right_text == *t) {
2417                    return Some((left_text, right_text, line, false));
2418                }
2419                None
2420            }
2421            "let_declaration" | "short_var_declaration" => {
2422                // Rust / Go
2423                let left = node
2424                    .child_by_field_name("pattern")
2425                    .or_else(|| node.child_by_field_name("left"))?;
2426                let right = node
2427                    .child_by_field_name("value")
2428                    .or_else(|| node.child_by_field_name("right"))?;
2429                let left_text = node_text(left, source);
2430                let right_text = node_text(right, source);
2431
2432                if tracked_names.iter().any(|t| right_text == *t) {
2433                    return Some((left_text, right_text, line, false));
2434                }
2435                None
2436            }
2437            _ => None,
2438        }
2439    }
2440
2441    /// Check if a call expression uses a tracked name as an argument, and if so,
2442    /// resolve the callee and recurse into its body tracking the parameter name.
2443    #[allow(clippy::too_many_arguments)]
2444    fn check_call_for_data_flow(
2445        &mut self,
2446        node: tree_sitter::Node,
2447        source: &str,
2448        tracked_names: &[String],
2449        file: &Path,
2450        _symbol: &str,
2451        rel_file: &str,
2452        _lang: LangId,
2453        max_depth: usize,
2454        current_depth: usize,
2455        hops: &mut Vec<DataFlowHop>,
2456        depth_limited: &mut bool,
2457        visited: &mut HashSet<(PathBuf, String, String)>,
2458    ) {
2459        // Find the arguments node
2460        let args_node = find_child_by_kind(node, "arguments")
2461            .or_else(|| find_child_by_kind(node, "argument_list"));
2462
2463        let args_node = match args_node {
2464            Some(n) => n,
2465            None => return,
2466        };
2467
2468        // Collect argument texts and find which position a tracked name appears at
2469        let mut arg_positions: Vec<(usize, String)> = Vec::new(); // (position, tracked_name)
2470        let mut arg_idx = 0;
2471
2472        let mut cursor = args_node.walk();
2473        if cursor.goto_first_child() {
2474            loop {
2475                let child = cursor.node();
2476                let child_kind = child.kind();
2477
2478                // Skip punctuation (parentheses, commas)
2479                if child_kind == "(" || child_kind == ")" || child_kind == "," {
2480                    if !cursor.goto_next_sibling() {
2481                        break;
2482                    }
2483                    continue;
2484                }
2485
2486                let arg_text = node_text(child, source);
2487
2488                // Check for spread element — approximate
2489                if child_kind == "spread_element" || child_kind == "dictionary_splat" {
2490                    if tracked_names.iter().any(|t| arg_text.contains(t)) {
2491                        hops.push(DataFlowHop {
2492                            file: rel_file.to_string(),
2493                            symbol: _symbol.to_string(),
2494                            variable: arg_text,
2495                            line: child.start_position().row as u32 + 1,
2496                            flow_type: "parameter".to_string(),
2497                            approximate: true,
2498                        });
2499                    }
2500                    if !cursor.goto_next_sibling() {
2501                        break;
2502                    }
2503                    arg_idx += 1;
2504                    continue;
2505                }
2506
2507                if tracked_names.iter().any(|t| arg_text == *t) {
2508                    arg_positions.push((arg_idx, arg_text));
2509                }
2510
2511                arg_idx += 1;
2512                if !cursor.goto_next_sibling() {
2513                    break;
2514                }
2515            }
2516        }
2517
2518        if arg_positions.is_empty() {
2519            return;
2520        }
2521
2522        // Resolve the callee
2523        let (full_callee, short_callee) = extract_callee_names(node, source);
2524        let full_callee = match full_callee {
2525            Some(f) => f,
2526            None => return,
2527        };
2528        let short_callee = match short_callee {
2529            Some(s) => s,
2530            None => return,
2531        };
2532
2533        // Try to resolve cross-file edge
2534        let import_block = {
2535            match self.data.get(file) {
2536                Some(fd) => fd.import_block.clone(),
2537                None => return,
2538            }
2539        };
2540
2541        let edge = self.resolve_cross_file_edge(&full_callee, &short_callee, file, &import_block);
2542
2543        match edge {
2544            EdgeResolution::Resolved {
2545                file: target_file,
2546                symbol: target_symbol,
2547            } => {
2548                if current_depth + 1 > max_depth {
2549                    *depth_limited = true;
2550                    return;
2551                }
2552
2553                // Build target file to get parameter info
2554                if let Err(e) = self.build_file(&target_file) {
2555                    log::debug!(
2556                        "callgraph: skipping target file {}: {}",
2557                        target_file.display(),
2558                        e
2559                    );
2560                }
2561                let (params, target_line) = {
2562                    match self.lookup_file_data(&target_file) {
2563                        Some(fd) => {
2564                            let meta = fd.symbol_metadata.get(&target_symbol);
2565                            let sig = meta.and_then(|m| m.signature.clone());
2566                            let params = sig
2567                                .as_deref()
2568                                .map(|s| extract_parameters(s, fd.lang))
2569                                .unwrap_or_default();
2570                            let line = meta.map(|m| m.line).unwrap_or(1);
2571                            (params, line)
2572                        }
2573                        None => return,
2574                    }
2575                };
2576
2577                let target_rel = self.relative_path(&target_file);
2578
2579                for (pos, _tracked) in &arg_positions {
2580                    if let Some(param_name) = params.get(*pos) {
2581                        // Add parameter hop
2582                        hops.push(DataFlowHop {
2583                            file: target_rel.clone(),
2584                            symbol: target_symbol.clone(),
2585                            variable: param_name.clone(),
2586                            line: target_line,
2587                            flow_type: "parameter".to_string(),
2588                            approximate: false,
2589                        });
2590
2591                        // Recurse into callee's body tracking the parameter name
2592                        self.trace_data_inner(
2593                            &target_file.clone(),
2594                            &target_symbol.clone(),
2595                            param_name,
2596                            max_depth,
2597                            current_depth + 1,
2598                            hops,
2599                            depth_limited,
2600                            visited,
2601                        );
2602                    }
2603                }
2604            }
2605            EdgeResolution::Unresolved { callee_name } => {
2606                let local_symbol = if is_bare_callee(&full_callee, &callee_name) {
2607                    self.data
2608                        .get(file)
2609                        .and_then(|fd| resolve_symbol_query_in_data(fd, file, &callee_name).ok())
2610                } else {
2611                    None
2612                };
2613
2614                if let Some(local_symbol) = local_symbol {
2615                    // Same-file bare call — get param info
2616                    let (params, target_line) = {
2617                        let Some(fd) = self.data.get(file) else {
2618                            return;
2619                        };
2620                        let meta = fd.symbol_metadata.get(&local_symbol);
2621                        let sig = meta.and_then(|m| m.signature.clone());
2622                        let params = sig
2623                            .as_deref()
2624                            .map(|s| extract_parameters(s, fd.lang))
2625                            .unwrap_or_default();
2626                        let line = meta.map(|m| m.line).unwrap_or(1);
2627                        (params, line)
2628                    };
2629
2630                    let file_rel = self.relative_path(file);
2631
2632                    for (pos, _tracked) in &arg_positions {
2633                        if let Some(param_name) = params.get(*pos) {
2634                            hops.push(DataFlowHop {
2635                                file: file_rel.clone(),
2636                                symbol: local_symbol.clone(),
2637                                variable: param_name.clone(),
2638                                line: target_line,
2639                                flow_type: "parameter".to_string(),
2640                                approximate: false,
2641                            });
2642
2643                            // Recurse into same-file function
2644                            self.trace_data_inner(
2645                                file,
2646                                &local_symbol,
2647                                param_name,
2648                                max_depth,
2649                                current_depth + 1,
2650                                hops,
2651                                depth_limited,
2652                                visited,
2653                            );
2654                        }
2655                    }
2656                } else {
2657                    // Truly unresolved — approximate hop
2658                    for (_pos, tracked) in &arg_positions {
2659                        hops.push(DataFlowHop {
2660                            file: self.relative_path(file),
2661                            symbol: callee_name.clone(),
2662                            variable: tracked.clone(),
2663                            line: node.start_position().row as u32 + 1,
2664                            flow_type: "parameter".to_string(),
2665                            approximate: true,
2666                        });
2667                    }
2668                }
2669            }
2670        }
2671    }
2672
2673    /// Read a single source line (1-based) from a file, trimmed.
2674    fn read_source_line(&self, path: &Path, line: u32) -> Option<String> {
2675        let content = std::fs::read_to_string(path).ok()?;
2676        content
2677            .lines()
2678            .nth(line.saturating_sub(1) as usize)
2679            .map(|l| l.trim().to_string())
2680    }
2681
2682    /// Recursively collect callers up to the given depth.
2683    fn collect_callers_recursive(
2684        &self,
2685        file: &Path,
2686        symbol: &str,
2687        max_depth: usize,
2688        current_depth: usize,
2689        visited: &mut HashSet<(PathBuf, SharedStr)>,
2690        result: &mut Vec<CallerSite>,
2691        depth_limited: &mut bool,
2692        truncated: &mut usize,
2693    ) {
2694        // Canonicalize for consistent reverse index lookup
2695        let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
2696        let key_symbol: SharedStr = Arc::from(symbol);
2697
2698        if current_depth >= max_depth {
2699            let omitted = self
2700                .reverse_sites(&canon, key_symbol.as_ref())
2701                .map(|sites| sites.len())
2702                .unwrap_or(0);
2703            if omitted > 0 {
2704                *depth_limited = true;
2705                *truncated += omitted;
2706            }
2707            return;
2708        }
2709
2710        if !visited.insert((canon.clone(), Arc::clone(&key_symbol))) {
2711            return; // cycle detection
2712        }
2713
2714        if let Some(sites) = self.reverse_sites(&canon, key_symbol.as_ref()) {
2715            for site in sites {
2716                result.push(CallerSite {
2717                    caller_file: site.caller_file.as_ref().clone(),
2718                    caller_symbol: site.caller_symbol.to_string(),
2719                    line: site.line,
2720                    col: site.col,
2721                    resolved: site.resolved,
2722                });
2723                // Recurse: find callers of the caller
2724                if current_depth + 1 < max_depth {
2725                    self.collect_callers_recursive(
2726                        site.caller_file.as_ref(),
2727                        site.caller_symbol.as_ref(),
2728                        max_depth,
2729                        current_depth + 1,
2730                        visited,
2731                        result,
2732                        depth_limited,
2733                        truncated,
2734                    );
2735                } else {
2736                    let omitted = self
2737                        .reverse_sites(site.caller_file.as_ref(), site.caller_symbol.as_ref())
2738                        .map(|sites| sites.len())
2739                        .unwrap_or(0);
2740                    if omitted > 0 {
2741                        *depth_limited = true;
2742                        *truncated += omitted;
2743                    }
2744                }
2745            }
2746        }
2747    }
2748
2749    /// Invalidate a file: remove its cached data and clear the reverse index.
2750    ///
2751    /// Called by the file watcher when a file changes on disk. The reverse
2752    /// index is rebuilt lazily on the next `callers_of` call.
2753    pub fn invalidate_file(&mut self, path: &Path) {
2754        // Remove from data cache (try both as-is and canonicalized)
2755        self.data.remove(path);
2756        if let Ok(canon) = self.canonicalize(path) {
2757            self.data.remove(&canon);
2758        }
2759        // Clear the reverse index — it's stale
2760        self.reverse_index = None;
2761        // Clear project_files cache for create/remove events
2762        self.project_files = None;
2763        clear_workspace_package_cache();
2764    }
2765
2766    /// Return a path relative to the project root, or the absolute path if
2767    /// it's outside the project.
2768    fn relative_path(&self, path: &Path) -> String {
2769        // Emit forward slashes on every platform so the agent-facing `file`
2770        // field is consistent across the whole call-graph surface (the
2771        // persisted store's `relative_path` already normalizes to `/`, and
2772        // Windows accepts `/` as a path input). Without this, legacy ops
2773        // (trace_data, dead_code) emitted `src\foo.ts` on Windows while
2774        // store-backed ops emitted `src/foo.ts`.
2775        path.strip_prefix(&self.project_root)
2776            .unwrap_or(path)
2777            .to_string_lossy()
2778            .replace('\\', "/")
2779    }
2780
2781    /// Canonicalize a path, falling back to the original if canonicalization fails.
2782    fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
2783        // If the path is relative, resolve it against project_root
2784        let full_path = if path.is_relative() {
2785            self.project_root.join(path)
2786        } else {
2787            path.to_path_buf()
2788        };
2789
2790        // Try canonicalize, fall back to the full path
2791        Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
2792    }
2793
2794    /// Look up cached file data, trying both the given path and its
2795    /// canonicalized form. Needed because `build_reverse_index` may store
2796    /// data under raw walker paths while CallerSite uses canonical paths.
2797    fn lookup_file_data(&self, path: &Path) -> Option<&FileCallData> {
2798        if let Some(fd) = self.data.get(path) {
2799            return Some(fd);
2800        }
2801        // Try canonical
2802        let canon = std::fs::canonicalize(path).ok()?;
2803        self.data.get(&canon).or_else(|| {
2804            // Try non-canonical forms stored by the walker
2805            self.data.iter().find_map(|(k, v)| {
2806                if std::fs::canonicalize(k).ok().as_ref() == Some(&canon) {
2807                    Some(v)
2808                } else {
2809                    None
2810                }
2811            })
2812        })
2813    }
2814}
2815
2816// ---------------------------------------------------------------------------
2817// File-level building
2818// ---------------------------------------------------------------------------
2819
2820/// Build call data for a single file.
2821/// Bounded rayon pool for the cold parse pass: half the cores (cap 8), 8MB
2822/// worker stacks. Built fresh per cold build (infrequent, and the parse cost
2823/// dominates the pool-spawn cost). Bounds the parse so it never monopolizes
2824/// every core and starves the single-threaded bridge — matching
2825/// `callgraph_store::build_pool_size` and `inspect::dispatch::default_pool_size`.
2826fn callgraph_parse_pool() -> rayon::ThreadPool {
2827    let threads = std::thread::available_parallelism()
2828        .map(|p| p.get())
2829        .unwrap_or(1)
2830        .div_ceil(2)
2831        .clamp(1, 8);
2832    rayon::ThreadPoolBuilder::new()
2833        .num_threads(threads)
2834        .thread_name(|i| format!("aft-callgraph-{i}"))
2835        .stack_size(8 * 1024 * 1024)
2836        .build()
2837        .unwrap_or_else(|_| {
2838            // Fallback: a 1-thread pool (still off the global pool).
2839            rayon::ThreadPoolBuilder::new()
2840                .num_threads(1)
2841                .build()
2842                .expect("single-thread rayon pool must build")
2843        })
2844}
2845
2846pub(crate) fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
2847    let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
2848        message: format!("unsupported file for call graph: {}", path.display()),
2849    })?;
2850
2851    let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
2852        path: format!("{}: {}", path.display(), e),
2853    })?;
2854
2855    let grammar = grammar_for(lang);
2856    let mut parser = Parser::new();
2857    parser
2858        .set_language(&grammar)
2859        .map_err(|e| AftError::ParseError {
2860            message: format!("grammar init failed for {:?}: {}", lang, e),
2861        })?;
2862
2863    let tree = parser
2864        .parse(&source, None)
2865        .ok_or_else(|| AftError::ParseError {
2866            message: format!("parse failed for {}", path.display()),
2867        })?;
2868
2869    // Parse imports
2870    let import_block = imports::parse_imports(&source, &tree, lang);
2871
2872    // Get symbols (for call site extraction and export detection)
2873    let symbols = crate::parser::extract_symbols_from_tree(&source, &tree, lang)?;
2874
2875    // Build calls_by_symbol
2876    let mut calls_by_symbol: HashMap<String, Vec<CallSite>> = HashMap::new();
2877    let root = tree.root_node();
2878
2879    for sym in &symbols {
2880        let byte_start = line_col_to_byte(&source, sym.range.start_line, sym.range.start_col);
2881        let byte_end = line_col_to_byte(&source, sym.range.end_line, sym.range.end_col);
2882
2883        let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2884
2885        let sites: Vec<CallSite> = raw_calls
2886            .into_iter()
2887            .map(
2888                |(full, short, line, call_byte_start, call_byte_end)| CallSite {
2889                    callee_name: short,
2890                    full_callee: full,
2891                    line,
2892                    byte_start: call_byte_start,
2893                    byte_end: call_byte_end,
2894                },
2895            )
2896            .collect();
2897
2898        if !sites.is_empty() {
2899            calls_by_symbol.insert(symbol_identity(sym), sites);
2900        }
2901    }
2902
2903    let symbol_ranges: Vec<(usize, usize)> = symbols
2904        .iter()
2905        .map(|sym| {
2906            (
2907                line_col_to_byte(&source, sym.range.start_line, sym.range.start_col),
2908                line_col_to_byte(&source, sym.range.end_line, sym.range.end_col),
2909            )
2910        })
2911        .collect();
2912
2913    let top_level_sites: Vec<CallSite> =
2914        collect_calls_full_with_ranges(root, &source, 0, source.len(), lang)
2915            .into_iter()
2916            .filter(|site| {
2917                !symbol_ranges
2918                    .iter()
2919                    .any(|(start, end)| site.byte_start >= *start && site.byte_end <= *end)
2920            })
2921            .map(|site| CallSite {
2922                callee_name: site.short,
2923                full_callee: site.full,
2924                line: site.line,
2925                byte_start: site.byte_start,
2926                byte_end: site.byte_end,
2927            })
2928            .collect();
2929
2930    if !top_level_sites.is_empty() {
2931        calls_by_symbol.insert(TOP_LEVEL_SYMBOL.to_string(), top_level_sites);
2932    }
2933
2934    let default_export = find_default_export(&source, root, path, lang);
2935
2936    if let Some(default_export) = &default_export {
2937        if default_export.synthetic {
2938            let byte_start = default_export.node.byte_range().start;
2939            let byte_end = default_export.node.byte_range().end;
2940            let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2941            let sites: Vec<CallSite> = raw_calls
2942                .into_iter()
2943                .filter(|(_, short, _, _, _)| *short != default_export.symbol)
2944                .map(
2945                    |(full, short, line, call_byte_start, call_byte_end)| CallSite {
2946                        callee_name: short,
2947                        full_callee: full,
2948                        line,
2949                        byte_start: call_byte_start,
2950                        byte_end: call_byte_end,
2951                    },
2952                )
2953                .collect();
2954            if !sites.is_empty() {
2955                calls_by_symbol.insert(default_export.symbol.clone(), sites);
2956            }
2957        }
2958    }
2959
2960    // Collect exported symbol names
2961    let mut exported_symbols: Vec<String> = symbols
2962        .iter()
2963        .filter(|s| s.exported)
2964        .map(|s| s.name.clone())
2965        .collect();
2966    if let Some(default_export) = &default_export {
2967        if !exported_symbols
2968            .iter()
2969            .any(|name| name == &default_export.symbol)
2970        {
2971            exported_symbols.push(default_export.symbol.clone());
2972        }
2973    }
2974
2975    // Build per-symbol metadata for entry point detection
2976    let mut symbol_metadata: HashMap<String, SymbolMeta> = symbols
2977        .iter()
2978        .map(|s| {
2979            (
2980                symbol_identity(s),
2981                SymbolMeta {
2982                    kind: s.kind.clone(),
2983                    exported: s.exported,
2984                    signature: s.signature.clone(),
2985                    line: s.range.start_line + 1,
2986                    range: s.range.clone(),
2987                },
2988            )
2989        })
2990        .collect();
2991    if let Some(default_export) = &default_export {
2992        symbol_metadata
2993            .entry(default_export.symbol.clone())
2994            .or_insert_with(|| SymbolMeta {
2995                kind: default_export.kind.clone(),
2996                exported: true,
2997                signature: Some(first_line_signature(&source, &default_export.node)),
2998                line: default_export.node.start_position().row as u32 + 1,
2999                range: crate::parser::node_range(&default_export.node),
3000            });
3001    }
3002    if calls_by_symbol.contains_key(TOP_LEVEL_SYMBOL) {
3003        symbol_metadata
3004            .entry(TOP_LEVEL_SYMBOL.to_string())
3005            .or_insert(SymbolMeta {
3006                kind: SymbolKind::Function,
3007                exported: false,
3008                signature: None,
3009                line: 1,
3010                range: Range {
3011                    start_line: 0,
3012                    start_col: 0,
3013                    end_line: 0,
3014                    end_col: 0,
3015                },
3016            });
3017    }
3018
3019    Ok(FileCallData {
3020        calls_by_symbol,
3021        exported_symbols,
3022        symbol_metadata,
3023        default_export_symbol: default_export.map(|export| export.symbol),
3024        import_block,
3025        lang,
3026    })
3027}
3028
3029#[derive(Debug, Clone)]
3030struct DefaultExport<'tree> {
3031    symbol: String,
3032    synthetic: bool,
3033    kind: SymbolKind,
3034    node: Node<'tree>,
3035}
3036
3037fn find_default_export<'tree>(
3038    source: &str,
3039    root: Node<'tree>,
3040    path: &Path,
3041    lang: LangId,
3042) -> Option<DefaultExport<'tree>> {
3043    if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
3044        return None;
3045    }
3046    find_default_export_inner(source, root, path)
3047}
3048
3049fn find_default_export_inner<'tree>(
3050    source: &str,
3051    node: Node<'tree>,
3052    path: &Path,
3053) -> Option<DefaultExport<'tree>> {
3054    if node.kind() == "export_statement" {
3055        if let Some(default_export) = default_export_from_statement(source, node, path) {
3056            return Some(default_export);
3057        }
3058    }
3059
3060    let mut cursor = node.walk();
3061    if !cursor.goto_first_child() {
3062        return None;
3063    }
3064
3065    loop {
3066        let child = cursor.node();
3067        if let Some(default_export) = find_default_export_inner(source, child, path) {
3068            return Some(default_export);
3069        }
3070        if !cursor.goto_next_sibling() {
3071            break;
3072        }
3073    }
3074
3075    None
3076}
3077
3078fn default_export_from_statement<'tree>(
3079    source: &str,
3080    node: Node<'tree>,
3081    path: &Path,
3082) -> Option<DefaultExport<'tree>> {
3083    let mut cursor = node.walk();
3084    if !cursor.goto_first_child() {
3085        return None;
3086    }
3087
3088    let mut saw_default = false;
3089    loop {
3090        let child = cursor.node();
3091        match child.kind() {
3092            "default" => saw_default = true,
3093            "function_declaration" | "generator_function_declaration" | "class_declaration"
3094                if saw_default =>
3095            {
3096                if let Some(name_node) = child.child_by_field_name("name") {
3097                    return Some(DefaultExport {
3098                        symbol: source[name_node.byte_range()].to_string(),
3099                        synthetic: false,
3100                        kind: default_export_kind(&child),
3101                        node: child,
3102                    });
3103                }
3104                return Some(DefaultExport {
3105                    symbol: synthetic_default_symbol(path),
3106                    synthetic: true,
3107                    kind: default_export_kind(&child),
3108                    node: child,
3109                });
3110            }
3111            "arrow_function"
3112            | "function"
3113            | "function_expression"
3114            | "class"
3115            | "class_expression"
3116                if saw_default =>
3117            {
3118                return Some(DefaultExport {
3119                    symbol: synthetic_default_symbol(path),
3120                    synthetic: true,
3121                    kind: default_export_kind(&child),
3122                    node: child,
3123                });
3124            }
3125            "identifier" | "type_identifier" | "property_identifier" if saw_default => {
3126                return Some(DefaultExport {
3127                    symbol: source[child.byte_range()].to_string(),
3128                    synthetic: false,
3129                    kind: SymbolKind::Function,
3130                    node: child,
3131                });
3132            }
3133            _ => {}
3134        }
3135        if !cursor.goto_next_sibling() {
3136            break;
3137        }
3138    }
3139
3140    None
3141}
3142
3143fn default_export_kind(node: &Node) -> SymbolKind {
3144    if node.kind().contains("class") {
3145        SymbolKind::Class
3146    } else {
3147        SymbolKind::Function
3148    }
3149}
3150
3151fn synthetic_default_symbol(path: &Path) -> String {
3152    let file_name = path
3153        .file_name()
3154        .and_then(|name| name.to_str())
3155        .unwrap_or("unknown");
3156    format!("<default:{file_name}>")
3157}
3158
3159fn first_line_signature(source: &str, node: &Node) -> String {
3160    let text = &source[node.byte_range()];
3161    let first_line = text.lines().next().unwrap_or(text);
3162    first_line
3163        .trim_end()
3164        .trim_end_matches('{')
3165        .trim_end()
3166        .to_string()
3167}
3168
3169fn get_symbol_meta_from_data(file_data: &FileCallData, symbol_name: &str) -> (u32, Option<String>) {
3170    file_data
3171        .symbol_metadata
3172        .get(symbol_name)
3173        .map(|meta| (meta.line, meta.signature.clone()))
3174        .unwrap_or((1, None))
3175}
3176
3177/// Get symbol metadata (line, signature) from a file.
3178fn get_symbol_meta(path: &Path, symbol_name: &str) -> (u32, Option<String>) {
3179    let provider = crate::parser::TreeSitterProvider::new();
3180    match provider.list_symbols(path) {
3181        Ok(symbols) => {
3182            for s in &symbols {
3183                if symbol_identity(s) == symbol_name || s.name == symbol_name {
3184                    return (s.range.start_line + 1, s.signature.clone());
3185                }
3186            }
3187            (1, None)
3188        }
3189        Err(_) => (1, None),
3190    }
3191}
3192
3193// ---------------------------------------------------------------------------
3194// Data flow tracking helpers
3195// ---------------------------------------------------------------------------
3196
3197/// Get the text of a tree-sitter node from the source.
3198fn node_text(node: tree_sitter::Node, source: &str) -> String {
3199    source[node.start_byte()..node.end_byte()].to_string()
3200}
3201
3202/// Find the smallest node that fully covers a byte range.
3203fn find_node_covering_range(
3204    root: tree_sitter::Node,
3205    start: usize,
3206    end: usize,
3207) -> Option<tree_sitter::Node> {
3208    let mut best = None;
3209    let mut cursor = root.walk();
3210
3211    fn walk_covering<'a>(
3212        cursor: &mut tree_sitter::TreeCursor<'a>,
3213        start: usize,
3214        end: usize,
3215        best: &mut Option<tree_sitter::Node<'a>>,
3216    ) {
3217        let node = cursor.node();
3218        if node.start_byte() <= start && node.end_byte() >= end {
3219            *best = Some(node);
3220            if cursor.goto_first_child() {
3221                loop {
3222                    walk_covering(cursor, start, end, best);
3223                    if !cursor.goto_next_sibling() {
3224                        break;
3225                    }
3226                }
3227                cursor.goto_parent();
3228            }
3229        }
3230    }
3231
3232    walk_covering(&mut cursor, start, end, &mut best);
3233    best
3234}
3235
3236/// Find a direct child node by kind name.
3237fn find_child_by_kind<'a>(
3238    node: tree_sitter::Node<'a>,
3239    kind: &str,
3240) -> Option<tree_sitter::Node<'a>> {
3241    let mut cursor = node.walk();
3242    if cursor.goto_first_child() {
3243        loop {
3244            if cursor.node().kind() == kind {
3245                return Some(cursor.node());
3246            }
3247            if !cursor.goto_next_sibling() {
3248                break;
3249            }
3250        }
3251    }
3252    None
3253}
3254
3255#[derive(Debug, Clone)]
3256struct CallSiteWithRange {
3257    full: String,
3258    short: String,
3259    line: u32,
3260    byte_start: usize,
3261    byte_end: usize,
3262}
3263
3264fn collect_calls_full_with_ranges(
3265    root: tree_sitter::Node,
3266    source: &str,
3267    byte_start: usize,
3268    byte_end: usize,
3269    lang: LangId,
3270) -> Vec<CallSiteWithRange> {
3271    let mut results = Vec::new();
3272    let call_kinds = call_node_kinds(lang);
3273    collect_calls_full_with_ranges_inner(
3274        root,
3275        source,
3276        byte_start,
3277        byte_end,
3278        &call_kinds,
3279        &mut results,
3280    );
3281    results
3282}
3283
3284fn collect_calls_full_with_ranges_inner(
3285    node: tree_sitter::Node,
3286    source: &str,
3287    byte_start: usize,
3288    byte_end: usize,
3289    call_kinds: &[&str],
3290    results: &mut Vec<CallSiteWithRange>,
3291) {
3292    let node_start = node.start_byte();
3293    let node_end = node.end_byte();
3294
3295    if node_end <= byte_start || node_start >= byte_end {
3296        return;
3297    }
3298
3299    if call_kinds.contains(&node.kind()) && node_start >= byte_start && node_end <= byte_end {
3300        if let (Some(full), Some(short)) = (
3301            extract_full_callee(&node, source),
3302            extract_callee_name(&node, source),
3303        ) {
3304            results.push(CallSiteWithRange {
3305                full,
3306                short,
3307                line: node.start_position().row as u32 + 1,
3308                byte_start: node_start,
3309                byte_end: node_end,
3310            });
3311        }
3312    }
3313
3314    let mut cursor = node.walk();
3315    if cursor.goto_first_child() {
3316        loop {
3317            collect_calls_full_with_ranges_inner(
3318                cursor.node(),
3319                source,
3320                byte_start,
3321                byte_end,
3322                call_kinds,
3323                results,
3324            );
3325            if !cursor.goto_next_sibling() {
3326                break;
3327            }
3328        }
3329    }
3330}
3331
3332/// Extract full and short callee names from a call_expression node.
3333fn extract_callee_names(node: tree_sitter::Node, source: &str) -> (Option<String>, Option<String>) {
3334    // The "function" field holds the callee
3335    let callee = match node.child_by_field_name("function") {
3336        Some(c) => c,
3337        None => return (None, None),
3338    };
3339
3340    let full = node_text(callee, source);
3341    let short = if full.contains('.') {
3342        full.rsplit('.').next().unwrap_or(&full).to_string()
3343    } else {
3344        full.clone()
3345    };
3346
3347    (Some(full), Some(short))
3348}
3349
3350// ---------------------------------------------------------------------------
3351// Module path resolution
3352// ---------------------------------------------------------------------------
3353
3354/// Resolve a module path (e.g. './utils') relative to a directory.
3355///
3356/// Tries common file extensions for TypeScript/JavaScript projects.
3357pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3358    if module_path.starts_with('.') {
3359        return resolve_relative_module_path(from_dir, module_path);
3360    }
3361
3362    if module_path.starts_with('/') {
3363        return None;
3364    }
3365
3366    if let Some(path) = resolve_tsconfig_path(from_dir, module_path) {
3367        return Some(path);
3368    }
3369
3370    resolve_workspace_module_path(from_dir, module_path)
3371}
3372
3373fn resolve_relative_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3374    let base = from_dir.join(module_path);
3375    resolve_file_like_path(&base)
3376}
3377
3378fn resolve_file_like_path(base: &Path) -> Option<PathBuf> {
3379    let base = base.to_path_buf();
3380
3381    // Try exact path first
3382    if base.is_file() {
3383        return Some(std::fs::canonicalize(&base).unwrap_or(base));
3384    }
3385
3386    // Try common extensions, including ESM/CJS TypeScript pairs used by workspaces.
3387    for ext in JS_TS_EXTENSIONS {
3388        let with_ext = base.with_extension(ext);
3389        if with_ext.is_file() {
3390            return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
3391        }
3392    }
3393
3394    // Try as directory with index file
3395    if base.is_dir() {
3396        if let Some(index) = find_index_file(&base) {
3397            return Some(index);
3398        }
3399    }
3400
3401    None
3402}
3403
3404fn resolve_workspace_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3405    let (package_name, subpath) = split_package_import(module_path)?;
3406    let package_root = find_package_root_for_import(from_dir, &package_name)?;
3407    resolve_package_entry(&package_root, &subpath)
3408}
3409
3410fn is_rust_source_file(path: &Path) -> bool {
3411    path.extension().and_then(|ext| ext.to_str()) == Some("rs")
3412}
3413
3414fn resolve_rust_cross_file_edge<F>(
3415    full_callee: &str,
3416    short_name: &str,
3417    caller_file: &Path,
3418    import_block: &ImportBlock,
3419    file_exports_symbol: &mut F,
3420) -> Option<ResolvedSymbol>
3421where
3422    F: FnMut(&Path, &str) -> bool,
3423{
3424    if let Some(target) = resolve_rust_qualified_call(caller_file, full_callee, file_exports_symbol)
3425    {
3426        return Some(target);
3427    }
3428
3429    resolve_rust_imported_call(
3430        caller_file,
3431        full_callee,
3432        short_name,
3433        import_block,
3434        file_exports_symbol,
3435    )
3436}
3437
3438fn resolve_rust_qualified_call<F>(
3439    caller_file: &Path,
3440    full_callee: &str,
3441    file_exports_symbol: &mut F,
3442) -> Option<ResolvedSymbol>
3443where
3444    F: FnMut(&Path, &str) -> bool,
3445{
3446    if !full_callee.contains("::") {
3447        return None;
3448    }
3449
3450    let segments = rust_path_segments(full_callee)?;
3451    resolve_rust_call_segments(caller_file, &segments, file_exports_symbol)
3452}
3453
3454fn resolve_rust_imported_call<F>(
3455    caller_file: &Path,
3456    full_callee: &str,
3457    short_name: &str,
3458    import_block: &ImportBlock,
3459    file_exports_symbol: &mut F,
3460) -> Option<ResolvedSymbol>
3461where
3462    F: FnMut(&Path, &str) -> bool,
3463{
3464    let call_segments = rust_path_segments(full_callee).unwrap_or_default();
3465    let bare_call_name = if call_segments.len() <= 1 {
3466        call_segments
3467            .first()
3468            .map(String::as_str)
3469            .unwrap_or(short_name)
3470    } else {
3471        short_name
3472    };
3473
3474    for imp in &import_block.imports {
3475        for entry in rust_use_entries(imp) {
3476            match &entry.kind {
3477                RustUseKind::Item { imported_name } if call_segments.len() <= 1 => {
3478                    if entry.local_name != bare_call_name {
3479                        continue;
3480                    }
3481                    let Some(file) = resolve_rust_module_path(caller_file, &entry.module_path)
3482                    else {
3483                        continue;
3484                    };
3485                    if file_exports_symbol(&file, imported_name) {
3486                        return Some(ResolvedSymbol {
3487                            file,
3488                            symbol: imported_name.clone(),
3489                        });
3490                    }
3491                }
3492                RustUseKind::Module if call_segments.len() >= 2 => {
3493                    if call_segments.first().map(String::as_str) != Some(entry.local_name.as_str())
3494                    {
3495                        continue;
3496                    }
3497                    let symbol = call_segments.last()?.clone();
3498                    let mut module_path = entry.module_path.clone();
3499                    for segment in &call_segments[1..call_segments.len().saturating_sub(1)] {
3500                        module_path.push_str("::");
3501                        module_path.push_str(segment);
3502                    }
3503                    let Some(file) = resolve_rust_module_path(caller_file, &module_path) else {
3504                        continue;
3505                    };
3506                    if file_exports_symbol(&file, &symbol) {
3507                        return Some(ResolvedSymbol { file, symbol });
3508                    }
3509                }
3510                _ => {}
3511            }
3512        }
3513    }
3514
3515    None
3516}
3517
3518fn resolve_rust_call_segments<F>(
3519    caller_file: &Path,
3520    segments: &[String],
3521    file_exports_symbol: &mut F,
3522) -> Option<ResolvedSymbol>
3523where
3524    F: FnMut(&Path, &str) -> bool,
3525{
3526    if segments.len() < 2 {
3527        return None;
3528    }
3529
3530    let symbol = segments.last()?.clone();
3531    let module_path = segments[..segments.len() - 1].join("::");
3532    let file = resolve_rust_module_path(caller_file, &module_path)?;
3533    if file_exports_symbol(&file, &symbol) {
3534        Some(ResolvedSymbol { file, symbol })
3535    } else {
3536        None
3537    }
3538}
3539
3540fn resolve_rust_module_path(caller_file: &Path, module_path: &str) -> Option<PathBuf> {
3541    let segments = rust_path_segments(module_path)?;
3542    let first = segments.first()?.as_str();
3543
3544    match first {
3545        "std" | "core" | "alloc" => None,
3546        "crate" => {
3547            let crate_root = find_rust_crate_root(caller_file)?;
3548            let crate_info = rust_crate_info(&crate_root)?;
3549            let base = rust_module_base_for_caller(&crate_info, caller_file)?;
3550            resolve_rust_module_segments(&base, &segments[1..])
3551        }
3552        "self" => {
3553            let crate_root = find_rust_crate_root(caller_file)?;
3554            let crate_info = rust_crate_info(&crate_root)?;
3555            let base = rust_module_base_for_caller(&crate_info, caller_file)?;
3556            if segments.len() == 1 {
3557                return Some(canonicalize_path(caller_file));
3558            }
3559            let mut target_segments = rust_module_segments_for_file(&base.src_dir, caller_file)?;
3560            target_segments.extend(segments[1..].iter().cloned());
3561            resolve_rust_module_segments(&base, &target_segments)
3562        }
3563        "super" => {
3564            let crate_root = find_rust_crate_root(caller_file)?;
3565            let crate_info = rust_crate_info(&crate_root)?;
3566            let base = rust_module_base_for_caller(&crate_info, caller_file)?;
3567            let mut target_segments = rust_module_segments_for_file(&base.src_dir, caller_file)?;
3568            target_segments.pop();
3569            target_segments.extend(segments[1..].iter().cloned());
3570            resolve_rust_module_segments(&base, &target_segments)
3571        }
3572        crate_name => {
3573            let caller_dir = caller_file.parent().unwrap_or_else(|| Path::new("."));
3574            let workspace_crates = rust_workspace_crates(caller_dir)?;
3575            let crate_info = workspace_crates.get(crate_name)?;
3576            let base = rust_lib_module_base(crate_info)?;
3577            resolve_rust_module_segments(&base, &segments[1..])
3578        }
3579    }
3580}
3581
3582fn rust_use_entries(imp: &imports::ImportStatement) -> Vec<RustUseEntry> {
3583    let Some(body) = rust_use_body(&imp.raw_text) else {
3584        return Vec::new();
3585    };
3586    let mut entries = Vec::new();
3587    expand_rust_use_tree(body, &mut entries);
3588    entries
3589}
3590
3591fn rust_use_body(raw: &str) -> Option<&str> {
3592    let use_pos = raw.find("use ")?;
3593    let body = raw[use_pos + 4..].trim();
3594    let body = body.strip_suffix(';').unwrap_or(body).trim();
3595    (!body.is_empty()).then_some(body)
3596}
3597
3598fn expand_rust_use_tree(path: &str, entries: &mut Vec<RustUseEntry>) {
3599    let path = path.trim();
3600    if path.is_empty() {
3601        return;
3602    }
3603
3604    if let Some((prefix, inner)) = split_rust_use_braces(path) {
3605        let prefix = prefix.trim().trim_end_matches("::").trim();
3606        for part in split_top_level_commas(inner) {
3607            let part = part.trim();
3608            if part.is_empty() {
3609                continue;
3610            }
3611            if part == "self" {
3612                if let Some(local_name) = rust_last_path_segment(prefix) {
3613                    entries.push(RustUseEntry {
3614                        module_path: prefix.to_string(),
3615                        local_name,
3616                        kind: RustUseKind::Module,
3617                    });
3618                }
3619                continue;
3620            }
3621            let combined = if prefix.is_empty() {
3622                part.to_string()
3623            } else {
3624                format!("{prefix}::{part}")
3625            };
3626            expand_rust_use_tree(&combined, entries);
3627        }
3628        return;
3629    }
3630
3631    add_rust_use_leaf(path, entries);
3632}
3633
3634fn split_rust_use_braces(path: &str) -> Option<(&str, &str)> {
3635    let mut depth = 0usize;
3636    let mut start = None;
3637    for (idx, ch) in path.char_indices() {
3638        match ch {
3639            '{' => {
3640                if depth == 0 {
3641                    start = Some(idx);
3642                }
3643                depth += 1;
3644            }
3645            '}' => {
3646                depth = depth.checked_sub(1)?;
3647                if depth == 0 {
3648                    let start = start?;
3649                    if !path[idx + ch.len_utf8()..].trim().is_empty() {
3650                        return None;
3651                    }
3652                    return Some((&path[..start], &path[start + 1..idx]));
3653                }
3654            }
3655            _ => {}
3656        }
3657    }
3658    None
3659}
3660
3661fn split_top_level_commas(value: &str) -> Vec<&str> {
3662    let mut parts = Vec::new();
3663    let mut depth = 0usize;
3664    let mut start = 0usize;
3665    for (idx, ch) in value.char_indices() {
3666        match ch {
3667            '{' => depth += 1,
3668            '}' => depth = depth.saturating_sub(1),
3669            ',' if depth == 0 => {
3670                parts.push(&value[start..idx]);
3671                start = idx + ch.len_utf8();
3672            }
3673            _ => {}
3674        }
3675    }
3676    parts.push(&value[start..]);
3677    parts
3678}
3679
3680fn add_rust_use_leaf(path: &str, entries: &mut Vec<RustUseEntry>) {
3681    let (path, alias) = split_rust_alias(path);
3682    let Some(segments) = rust_path_segments(path) else {
3683        return;
3684    };
3685    if segments.is_empty() || segments.last().map(String::as_str) == Some("*") {
3686        return;
3687    }
3688
3689    let imported_name = segments.last().cloned().unwrap_or_default();
3690    let local_name = alias.unwrap_or(&imported_name).to_string();
3691    if segments.len() >= 2 {
3692        entries.push(RustUseEntry {
3693            module_path: segments[..segments.len() - 1].join("::"),
3694            local_name: local_name.clone(),
3695            kind: RustUseKind::Item {
3696                imported_name: imported_name.clone(),
3697            },
3698        });
3699    }
3700
3701    entries.push(RustUseEntry {
3702        module_path: segments.join("::"),
3703        local_name,
3704        kind: RustUseKind::Module,
3705    });
3706}
3707
3708fn split_rust_alias(path: &str) -> (&str, Option<&str>) {
3709    if let Some(idx) = path.rfind(" as ") {
3710        let original = path[..idx].trim();
3711        let alias = path[idx + 4..].trim();
3712        if !original.is_empty() && !alias.is_empty() {
3713            return (original, Some(alias));
3714        }
3715    }
3716    (path.trim(), None)
3717}
3718
3719fn rust_path_segments(path: &str) -> Option<Vec<String>> {
3720    let path = path.trim().trim_end_matches(';').trim();
3721    if path.is_empty() || path.contains('{') || path.contains('}') {
3722        return None;
3723    }
3724
3725    let mut segments = Vec::new();
3726    for raw_segment in path.split("::") {
3727        let segment = raw_segment.trim();
3728        if segment.is_empty() || segment == "*" || segment.chars().any(char::is_whitespace) {
3729            return None;
3730        }
3731        let segment = segment.strip_prefix("r#").unwrap_or(segment);
3732        if segment
3733            .chars()
3734            .any(|ch| !(ch == '_' || ch.is_ascii_alphanumeric()))
3735        {
3736            return None;
3737        }
3738        segments.push(segment.to_string());
3739    }
3740
3741    (!segments.is_empty()).then_some(segments)
3742}
3743
3744fn rust_last_path_segment(path: &str) -> Option<String> {
3745    rust_path_segments(path)?.last().cloned()
3746}
3747
3748fn find_rust_crate_root(from: &Path) -> Option<PathBuf> {
3749    let mut current = if from.is_file() {
3750        from.parent()
3751    } else {
3752        Some(from)
3753    };
3754    while let Some(dir) = current {
3755        if dir.join("Cargo.toml").is_file() {
3756            return Some(canonicalize_path(dir));
3757        }
3758        current = dir.parent();
3759    }
3760    None
3761}
3762
3763fn rust_crate_info(crate_root: &Path) -> Option<RustCrateInfo> {
3764    let root = canonicalize_path(crate_root);
3765    if let Some(cached) = RUST_CRATE_INFO_CACHE
3766        .read()
3767        .ok()
3768        .and_then(|cache| cache.get(&root).cloned())
3769    {
3770        return cached;
3771    }
3772
3773    let resolved = read_rust_crate_info(&root);
3774    if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
3775        cache.insert(root, resolved.clone());
3776    }
3777    resolved
3778}
3779
3780fn read_rust_crate_info(crate_root: &Path) -> Option<RustCrateInfo> {
3781    let cargo = rust_manifest_value(&crate_root.join("Cargo.toml"))?;
3782    let package = cargo.get("package")?;
3783    let package_name = package.get("name")?.as_str()?;
3784    let lib_name = cargo
3785        .get("lib")
3786        .and_then(|lib| lib.get("name"))
3787        .and_then(|name| name.as_str())
3788        .map(ToOwned::to_owned)
3789        .unwrap_or_else(|| package_name.replace('-', "_"));
3790
3791    let lib_root = cargo
3792        .get("lib")
3793        .and_then(|lib| lib.get("path"))
3794        .and_then(|path| path.as_str())
3795        .map(|path| crate_root.join(path))
3796        .unwrap_or_else(|| crate_root.join("src/lib.rs"));
3797    let lib_root = lib_root.is_file().then(|| canonicalize_path(&lib_root));
3798
3799    let main_root = crate_root.join("src/main.rs");
3800    let main_root = main_root.is_file().then(|| canonicalize_path(&main_root));
3801
3802    Some(RustCrateInfo {
3803        lib_name,
3804        lib_root,
3805        main_root,
3806    })
3807}
3808
3809fn rust_manifest_value(path: &Path) -> Option<toml::Value> {
3810    let source = std::fs::read_to_string(path).ok()?;
3811    toml::from_str(&source).ok()
3812}
3813
3814fn rust_module_base_for_caller(
3815    crate_info: &RustCrateInfo,
3816    caller_file: &Path,
3817) -> Option<RustModuleBase> {
3818    let caller = canonicalize_path(caller_file);
3819    if crate_info.main_root.as_ref() == Some(&caller) {
3820        return rust_main_module_base(crate_info);
3821    }
3822    rust_lib_module_base(crate_info).or_else(|| rust_main_module_base(crate_info))
3823}
3824
3825fn rust_lib_module_base(crate_info: &RustCrateInfo) -> Option<RustModuleBase> {
3826    let root_file = crate_info.lib_root.clone()?;
3827    let src_dir = root_file.parent()?.to_path_buf();
3828    Some(RustModuleBase { src_dir, root_file })
3829}
3830
3831fn rust_main_module_base(crate_info: &RustCrateInfo) -> Option<RustModuleBase> {
3832    let root_file = crate_info.main_root.clone()?;
3833    let src_dir = root_file.parent()?.to_path_buf();
3834    Some(RustModuleBase { src_dir, root_file })
3835}
3836
3837fn resolve_rust_module_segments(base: &RustModuleBase, segments: &[String]) -> Option<PathBuf> {
3838    if segments.is_empty() {
3839        return Some(base.root_file.clone());
3840    }
3841
3842    let module_base = segments
3843        .iter()
3844        .fold(base.src_dir.clone(), |path, segment| path.join(segment));
3845    let file_path = module_base.with_extension("rs");
3846    if file_path.is_file() {
3847        return Some(canonicalize_path(&file_path));
3848    }
3849
3850    let mod_path = module_base.join("mod.rs");
3851    if mod_path.is_file() {
3852        return Some(canonicalize_path(&mod_path));
3853    }
3854
3855    None
3856}
3857
3858fn rust_module_segments_for_file(src_dir: &Path, file: &Path) -> Option<Vec<String>> {
3859    let src_dir = canonicalize_path(src_dir);
3860    let file = canonicalize_path(file);
3861    let rel = file.strip_prefix(&src_dir).ok()?;
3862    let mut parts: Vec<String> = rel
3863        .components()
3864        .filter_map(|component| component.as_os_str().to_str().map(ToOwned::to_owned))
3865        .collect();
3866    if parts.is_empty() {
3867        return None;
3868    }
3869
3870    let last = parts.pop()?;
3871    if last == "lib.rs" || last == "main.rs" {
3872        return Some(Vec::new());
3873    }
3874    if last == "mod.rs" {
3875        return Some(parts);
3876    }
3877    let stem = Path::new(&last).file_stem()?.to_str()?.to_string();
3878    parts.push(stem);
3879    Some(parts)
3880}
3881
3882fn rust_workspace_crates(from_dir: &Path) -> Option<HashMap<String, RustCrateInfo>> {
3883    let workspace_root =
3884        find_rust_workspace_root(from_dir).or_else(|| find_rust_crate_root(from_dir))?;
3885    let workspace_root = canonicalize_path(&workspace_root);
3886
3887    if let Some(cached) = RUST_WORKSPACE_CRATE_CACHE
3888        .read()
3889        .ok()
3890        .and_then(|cache| cache.get(&workspace_root).cloned())
3891    {
3892        return Some(cached);
3893    }
3894
3895    let mut crates = HashMap::new();
3896    for member in rust_workspace_member_dirs(&workspace_root) {
3897        if let Some(info) = rust_crate_info(&member) {
3898            if info.lib_root.is_some() {
3899                crates.insert(info.lib_name.clone(), info);
3900            }
3901        }
3902    }
3903    if let Some(info) = rust_crate_info(&workspace_root) {
3904        if info.lib_root.is_some() {
3905            crates.insert(info.lib_name.clone(), info);
3906        }
3907    }
3908
3909    if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
3910        cache.insert(workspace_root, crates.clone());
3911    }
3912    Some(crates)
3913}
3914
3915fn find_rust_workspace_root(from_dir: &Path) -> Option<PathBuf> {
3916    let mut current = Some(from_dir);
3917    while let Some(dir) = current {
3918        let cargo = dir.join("Cargo.toml");
3919        if rust_manifest_value(&cargo)
3920            .and_then(|value| value.get("workspace").cloned())
3921            .is_some()
3922        {
3923            return Some(canonicalize_path(dir));
3924        }
3925        current = dir.parent();
3926    }
3927    None
3928}
3929
3930fn rust_workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
3931    let Some(cargo) = rust_manifest_value(&workspace_root.join("Cargo.toml")) else {
3932        return Vec::new();
3933    };
3934    let Some(members) = cargo
3935        .get("workspace")
3936        .and_then(|workspace| workspace.get("members"))
3937        .and_then(|members| members.as_array())
3938    else {
3939        return Vec::new();
3940    };
3941
3942    let mut dirs = Vec::new();
3943    for member in members.iter().filter_map(|member| member.as_str()) {
3944        dirs.extend(expand_rust_workspace_member(workspace_root, member));
3945    }
3946    dirs.sort();
3947    dirs.dedup();
3948    dirs
3949}
3950
3951fn expand_rust_workspace_member(workspace_root: &Path, member: &str) -> Vec<PathBuf> {
3952    let member = member.trim();
3953    if member.is_empty() {
3954        return Vec::new();
3955    }
3956
3957    if member.contains('*') || member.contains('?') || member.contains('[') {
3958        let pattern = workspace_root.join(member).to_string_lossy().to_string();
3959        return glob::glob(&pattern)
3960            .ok()
3961            .into_iter()
3962            .flatten()
3963            .filter_map(Result::ok)
3964            .filter(|path| path.join("Cargo.toml").is_file())
3965            .map(|path| canonicalize_path(&path))
3966            .collect();
3967    }
3968
3969    let path = workspace_root.join(member);
3970    if path.join("Cargo.toml").is_file() {
3971        vec![canonicalize_path(&path)]
3972    } else {
3973        Vec::new()
3974    }
3975}
3976
3977fn canonicalize_path(path: &Path) -> PathBuf {
3978    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
3979}
3980
3981fn resolve_tsconfig_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
3982    let tsconfig_dir = find_tsconfig_dir(from_dir)?;
3983    let tsconfig = package_json_like_value(&tsconfig_dir.join("tsconfig.json"))?;
3984    let compiler_options = tsconfig.get("compilerOptions")?;
3985    let paths = compiler_options.get("paths")?.as_object()?;
3986    let base_url = compiler_options
3987        .get("baseUrl")
3988        .and_then(Value::as_str)
3989        .unwrap_or(".");
3990    let base_dir = tsconfig_dir.join(base_url);
3991
3992    for (alias, targets) in paths {
3993        let Some(capture) = ts_path_capture(alias, module_path) else {
3994            continue;
3995        };
3996        let Some(targets) = targets.as_array() else {
3997            continue;
3998        };
3999        for target in targets.iter().filter_map(Value::as_str) {
4000            let target = if target.contains('*') {
4001                target.replace('*', capture)
4002            } else {
4003                target.to_string()
4004            };
4005            if let Some(path) = resolve_file_like_path(&base_dir.join(target)) {
4006                return Some(path);
4007            }
4008        }
4009    }
4010
4011    None
4012}
4013
4014fn find_tsconfig_dir(from_dir: &Path) -> Option<PathBuf> {
4015    let mut current = Some(from_dir);
4016    while let Some(dir) = current {
4017        if dir.join("tsconfig.json").is_file() {
4018            return Some(dir.to_path_buf());
4019        }
4020        current = dir.parent();
4021    }
4022    None
4023}
4024
4025fn ts_path_capture<'a>(alias: &str, module_path: &'a str) -> Option<&'a str> {
4026    if let Some(star_index) = alias.find('*') {
4027        let (prefix, suffix_with_star) = alias.split_at(star_index);
4028        let suffix = &suffix_with_star[1..];
4029        if module_path.starts_with(prefix) && module_path.ends_with(suffix) {
4030            return Some(&module_path[prefix.len()..module_path.len() - suffix.len()]);
4031        }
4032        return None;
4033    }
4034
4035    (alias == module_path).then_some("")
4036}
4037
4038fn split_package_import(module_path: &str) -> Option<(String, Option<String>)> {
4039    let mut parts = module_path.split('/');
4040    let first = parts.next()?;
4041    if first.is_empty() {
4042        return None;
4043    }
4044
4045    if first.starts_with('@') {
4046        let second = parts.next()?;
4047        if second.is_empty() {
4048            return None;
4049        }
4050        let package_name = format!("{first}/{second}");
4051        let subpath = parts.collect::<Vec<_>>().join("/");
4052        let subpath = (!subpath.is_empty()).then_some(subpath);
4053        Some((package_name, subpath))
4054    } else {
4055        let package_name = first.to_string();
4056        let subpath = parts.collect::<Vec<_>>().join("/");
4057        let subpath = (!subpath.is_empty()).then_some(subpath);
4058        Some((package_name, subpath))
4059    }
4060}
4061
4062fn find_package_root_for_import(from_dir: &Path, package_name: &str) -> Option<PathBuf> {
4063    let mut current = Some(from_dir);
4064    while let Some(dir) = current {
4065        if package_json_name(dir).as_deref() == Some(package_name) {
4066            return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
4067        }
4068        current = dir.parent();
4069    }
4070
4071    find_workspace_root(from_dir)
4072        .and_then(|workspace_root| resolve_workspace_package(&workspace_root, package_name))
4073}
4074
4075fn find_workspace_root(from_dir: &Path) -> Option<PathBuf> {
4076    let mut current = Some(from_dir);
4077    while let Some(dir) = current {
4078        if is_workspace_root(dir) {
4079            return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
4080        }
4081        current = dir.parent();
4082    }
4083    None
4084}
4085
4086fn is_workspace_root(dir: &Path) -> bool {
4087    package_json_value(dir)
4088        .map(|value| !workspace_patterns(&value).is_empty())
4089        .unwrap_or(false)
4090        || !pnpm_workspace_patterns(dir).is_empty()
4091}
4092
4093fn clear_workspace_package_cache() {
4094    if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
4095        cache.clear();
4096    }
4097    if let Ok(mut cache) = RUST_CRATE_INFO_CACHE.write() {
4098        cache.clear();
4099    }
4100    if let Ok(mut cache) = RUST_WORKSPACE_CRATE_CACHE.write() {
4101        cache.clear();
4102    }
4103}
4104
4105fn resolve_workspace_package(workspace_root: &Path, package_name: &str) -> Option<PathBuf> {
4106    let workspace_root =
4107        std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
4108    let cache_key = (workspace_root.clone(), package_name.to_string());
4109
4110    if let Ok(cache) = WORKSPACE_PACKAGE_CACHE.read() {
4111        if let Some(cached) = cache.get(&cache_key) {
4112            return cached.clone();
4113        }
4114    }
4115
4116    let resolved = workspace_member_dirs(&workspace_root)
4117        .into_iter()
4118        .find(|dir| package_json_name(dir).as_deref() == Some(package_name))
4119        .map(|dir| std::fs::canonicalize(&dir).unwrap_or(dir));
4120
4121    if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
4122        cache.insert(cache_key, resolved.clone());
4123    }
4124
4125    resolved
4126}
4127
4128fn workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
4129    let mut patterns = package_json_value(workspace_root)
4130        .map(|package_json| workspace_patterns(&package_json))
4131        .unwrap_or_default();
4132    patterns.extend(pnpm_workspace_patterns(workspace_root));
4133
4134    expand_workspace_patterns(workspace_root, &patterns)
4135}
4136
4137fn workspace_patterns(package_json: &Value) -> Vec<String> {
4138    match package_json.get("workspaces") {
4139        Some(Value::Array(items)) => items
4140            .iter()
4141            .filter_map(non_empty_workspace_pattern)
4142            .collect(),
4143        Some(Value::Object(map)) => map
4144            .get("packages")
4145            .and_then(Value::as_array)
4146            .map(|items| {
4147                items
4148                    .iter()
4149                    .filter_map(non_empty_workspace_pattern)
4150                    .collect()
4151            })
4152            .unwrap_or_default(),
4153        _ => Vec::new(),
4154    }
4155}
4156
4157fn non_empty_workspace_pattern(value: &Value) -> Option<String> {
4158    let pattern = value.as_str()?.trim();
4159    (!pattern.is_empty()).then(|| pattern.to_string())
4160}
4161
4162fn pnpm_workspace_patterns(workspace_root: &Path) -> Vec<String> {
4163    let Ok(source) = std::fs::read_to_string(workspace_root.join("pnpm-workspace.yaml")) else {
4164        return Vec::new();
4165    };
4166
4167    let mut patterns = Vec::new();
4168    let mut in_packages = false;
4169    for line in source.lines() {
4170        let without_comment = line.split('#').next().unwrap_or("").trim_end();
4171        let trimmed = without_comment.trim();
4172        if trimmed.is_empty() {
4173            continue;
4174        }
4175        if trimmed == "packages:" {
4176            in_packages = true;
4177            continue;
4178        }
4179        if !trimmed.starts_with('-') && !line.starts_with(' ') && !line.starts_with('\t') {
4180            in_packages = false;
4181        }
4182        if in_packages {
4183            if let Some(pattern) = trimmed.strip_prefix('-') {
4184                let pattern = pattern.trim().trim_matches('"').trim_matches('\'');
4185                if !pattern.is_empty() {
4186                    patterns.push(pattern.to_string());
4187                }
4188            }
4189        }
4190    }
4191    patterns
4192}
4193
4194fn expand_workspace_patterns(workspace_root: &Path, patterns: &[String]) -> Vec<PathBuf> {
4195    let positive_patterns: Vec<&str> = patterns
4196        .iter()
4197        .map(|pattern| pattern.trim())
4198        .filter(|pattern| !pattern.is_empty() && !pattern.starts_with('!'))
4199        .collect();
4200    if positive_patterns.is_empty() {
4201        return Vec::new();
4202    }
4203
4204    let positives = build_glob_set(&positive_patterns);
4205    let negative_patterns: Vec<&str> = patterns
4206        .iter()
4207        .map(|pattern| pattern.trim())
4208        .filter_map(|pattern| pattern.strip_prefix('!'))
4209        .map(str::trim)
4210        .filter(|pattern| !pattern.is_empty())
4211        .collect();
4212    let negatives = build_glob_set(&negative_patterns);
4213
4214    let mut members = Vec::new();
4215    collect_workspace_member_dirs(
4216        workspace_root,
4217        workspace_root,
4218        &positives,
4219        &negatives,
4220        &mut members,
4221    );
4222    members
4223}
4224
4225fn build_glob_set(patterns: &[&str]) -> GlobSet {
4226    let mut builder = GlobSetBuilder::new();
4227    for pattern in patterns {
4228        if let Ok(glob) = Glob::new(pattern) {
4229            builder.add(glob);
4230        }
4231    }
4232    builder
4233        .build()
4234        .unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap())
4235}
4236
4237fn collect_workspace_member_dirs(
4238    workspace_root: &Path,
4239    dir: &Path,
4240    positives: &GlobSet,
4241    negatives: &GlobSet,
4242    members: &mut Vec<PathBuf>,
4243) {
4244    let Ok(entries) = std::fs::read_dir(dir) else {
4245        return;
4246    };
4247
4248    for entry in entries.filter_map(Result::ok) {
4249        let path = entry.path();
4250        let Ok(file_type) = entry.file_type() else {
4251            continue;
4252        };
4253        if !file_type.is_dir() {
4254            continue;
4255        }
4256        let name = entry.file_name();
4257        let name = name.to_string_lossy();
4258        if matches!(
4259            name.as_ref(),
4260            "node_modules" | ".git" | "target" | "dist" | "build"
4261        ) {
4262            continue;
4263        }
4264
4265        if path.join("package.json").is_file() {
4266            if let Ok(rel) = path.strip_prefix(workspace_root) {
4267                let rel = rel.to_string_lossy().replace('\\', "/");
4268                if positives.is_match(&rel) && !negatives.is_match(&rel) {
4269                    members.push(path.clone());
4270                }
4271            }
4272        }
4273
4274        collect_workspace_member_dirs(workspace_root, &path, positives, negatives, members);
4275    }
4276}
4277
4278fn package_json_value(dir: &Path) -> Option<Value> {
4279    package_json_like_value(&dir.join("package.json"))
4280}
4281
4282fn package_json_like_value(path: &Path) -> Option<Value> {
4283    let json = std::fs::read_to_string(path).ok()?;
4284    serde_json::from_str(&json).ok()
4285}
4286
4287fn package_json_name(dir: &Path) -> Option<String> {
4288    package_json_value(dir)?
4289        .get("name")?
4290        .as_str()
4291        .map(ToOwned::to_owned)
4292}
4293
4294fn resolve_package_entry(package_root: &Path, subpath: &Option<String>) -> Option<PathBuf> {
4295    let package_json = package_json_value(package_root).unwrap_or(Value::Null);
4296
4297    if let Some(exports) = package_json.get("exports") {
4298        if let Some(target) = export_target_for_subpath(exports, subpath.as_deref()) {
4299            if let Some(path) = resolve_package_target(package_root, &target) {
4300                return Some(path);
4301            }
4302        }
4303    }
4304
4305    if subpath.is_none() {
4306        for field in ["module", "main"] {
4307            if let Some(target) = package_json.get(field).and_then(Value::as_str) {
4308                if let Some(path) = resolve_package_target(package_root, target) {
4309                    return Some(path);
4310                }
4311            }
4312        }
4313    }
4314
4315    resolve_package_fallback(package_root, subpath.as_deref())
4316}
4317
4318fn export_target_for_subpath(exports: &Value, subpath: Option<&str>) -> Option<String> {
4319    let key = subpath
4320        .map(|value| format!("./{value}"))
4321        .unwrap_or_else(|| ".".to_string());
4322
4323    match exports {
4324        Value::String(target) if key == "." => Some(target.clone()),
4325        Value::Object(map) => {
4326            if let Some(target) = map.get(&key).and_then(export_condition_target) {
4327                return Some(target);
4328            }
4329
4330            if let Some(target) = wildcard_export_target(map, &key) {
4331                return Some(target);
4332            }
4333
4334            if key == "." && !map.contains_key(".") && !map.keys().any(|k| k.starts_with("./")) {
4335                return export_condition_target(exports);
4336            }
4337
4338            None
4339        }
4340        _ => None,
4341    }
4342}
4343
4344fn wildcard_export_target(map: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
4345    for (pattern, target) in map {
4346        let Some(star_index) = pattern.find('*') else {
4347            continue;
4348        };
4349        let (prefix, suffix_with_star) = pattern.split_at(star_index);
4350        let suffix = &suffix_with_star[1..];
4351        if !key.starts_with(prefix) || !key.ends_with(suffix) {
4352            continue;
4353        }
4354        let matched = &key[prefix.len()..key.len() - suffix.len()];
4355        if let Some(target_pattern) = export_condition_target(target) {
4356            return Some(target_pattern.replace('*', matched));
4357        }
4358    }
4359    None
4360}
4361
4362fn export_condition_target(value: &Value) -> Option<String> {
4363    match value {
4364        Value::String(target) => Some(target.clone()),
4365        Value::Object(map) => ["source", "import", "module", "default", "types"]
4366            .into_iter()
4367            .find_map(|field| map.get(field).and_then(export_condition_target)),
4368        _ => None,
4369    }
4370}
4371
4372fn resolve_package_target(package_root: &Path, target: &str) -> Option<PathBuf> {
4373    let target = target.strip_prefix("./").unwrap_or(target);
4374    // Prefer source over compiled bundle when both exist: the callgraph
4375    // walks source files and cannot extract symbols from a built JS bundle.
4376    if let Some(src_relative) = target.strip_prefix("dist/") {
4377        if let Some(path) = resolve_file_like_path(&package_root.join("src").join(src_relative)) {
4378            return Some(path);
4379        }
4380    }
4381
4382    resolve_file_like_path(&package_root.join(target))
4383}
4384
4385fn resolve_package_fallback(package_root: &Path, subpath: Option<&str>) -> Option<PathBuf> {
4386    match subpath {
4387        Some(subpath) => resolve_file_like_path(&package_root.join(subpath))
4388            .or_else(|| resolve_file_like_path(&package_root.join("src").join(subpath))),
4389        None => resolve_file_like_path(&package_root.join("src").join("index"))
4390            .or_else(|| resolve_file_like_path(&package_root.join("index"))),
4391    }
4392}
4393
4394pub(crate) fn resolve_reexported_symbol_target<F, D>(
4395    file: &Path,
4396    symbol_name: &str,
4397    file_exports_symbol: &mut F,
4398    file_default_export_symbol: &mut D,
4399) -> Option<(PathBuf, String)>
4400where
4401    F: FnMut(&Path, &str) -> bool,
4402    D: FnMut(&Path) -> Option<String>,
4403{
4404    resolve_reexported_symbol(
4405        file,
4406        symbol_name,
4407        file_exports_symbol,
4408        file_default_export_symbol,
4409    )
4410    .map(|target| (target.file, target.symbol))
4411}
4412
4413fn resolve_reexported_symbol<F, D>(
4414    file: &Path,
4415    symbol_name: &str,
4416    file_exports_symbol: &mut F,
4417    file_default_export_symbol: &mut D,
4418) -> Option<ResolvedSymbol>
4419where
4420    F: FnMut(&Path, &str) -> bool,
4421    D: FnMut(&Path) -> Option<String>,
4422{
4423    let mut visited = HashSet::new();
4424    resolve_reexported_symbol_inner(
4425        file,
4426        symbol_name,
4427        file_exports_symbol,
4428        file_default_export_symbol,
4429        &mut visited,
4430    )
4431}
4432
4433fn resolve_reexported_symbol_inner<F, D>(
4434    file: &Path,
4435    symbol_name: &str,
4436    file_exports_symbol: &mut F,
4437    file_default_export_symbol: &mut D,
4438    visited: &mut HashSet<(PathBuf, String)>,
4439) -> Option<ResolvedSymbol>
4440where
4441    F: FnMut(&Path, &str) -> bool,
4442    D: FnMut(&Path) -> Option<String>,
4443{
4444    let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
4445    if !visited.insert((canon.clone(), symbol_name.to_string())) {
4446        return None;
4447    }
4448
4449    let source = std::fs::read_to_string(&canon).ok()?;
4450    let lang = detect_language(&canon)?;
4451    if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
4452        if symbol_name == "default" {
4453            return file_default_export_symbol(&canon).map(|symbol| ResolvedSymbol {
4454                file: canon,
4455                symbol,
4456            });
4457        }
4458        return file_exports_symbol(&canon, symbol_name).then(|| ResolvedSymbol {
4459            file: canon,
4460            symbol: symbol_name.to_string(),
4461        });
4462    }
4463
4464    let grammar = grammar_for(lang);
4465    let mut parser = Parser::new();
4466    parser.set_language(&grammar).ok()?;
4467    let tree = parser.parse(&source, None)?;
4468    let from_dir = canon.parent().unwrap_or_else(|| Path::new("."));
4469
4470    let mut cursor = tree.root_node().walk();
4471    if !cursor.goto_first_child() {
4472        return None;
4473    }
4474
4475    loop {
4476        let node = cursor.node();
4477        if node.kind() == "export_statement" {
4478            if let Some(target) = resolve_reexport_statement(
4479                &source,
4480                node,
4481                from_dir,
4482                symbol_name,
4483                file_exports_symbol,
4484                file_default_export_symbol,
4485                visited,
4486            ) {
4487                return Some(target);
4488            }
4489        }
4490
4491        if !cursor.goto_next_sibling() {
4492            break;
4493        }
4494    }
4495
4496    if symbol_name == "default" {
4497        if let Some(symbol) = file_default_export_symbol(&canon) {
4498            return Some(ResolvedSymbol {
4499                file: canon,
4500                symbol,
4501            });
4502        }
4503    }
4504
4505    if let Some(symbol) = resolve_local_export_alias(&source, &canon, symbol_name) {
4506        return Some(ResolvedSymbol {
4507            file: canon,
4508            symbol,
4509        });
4510    }
4511
4512    if file_exports_symbol(&canon, symbol_name) {
4513        let symbol = symbol_name.to_string();
4514        return Some(ResolvedSymbol {
4515            file: canon,
4516            symbol,
4517        });
4518    }
4519
4520    None
4521}
4522
4523fn resolve_reexport_statement<F, D>(
4524    source: &str,
4525    node: tree_sitter::Node,
4526    from_dir: &Path,
4527    symbol_name: &str,
4528    file_exports_symbol: &mut F,
4529    file_default_export_symbol: &mut D,
4530    visited: &mut HashSet<(PathBuf, String)>,
4531) -> Option<ResolvedSymbol>
4532where
4533    F: FnMut(&Path, &str) -> bool,
4534    D: FnMut(&Path) -> Option<String>,
4535{
4536    let source_node = node
4537        .child_by_field_name("source")
4538        .or_else(|| find_child_by_kind(node, "string"))?;
4539    let module_path = string_literal_content(source, source_node)?;
4540    let target_file = resolve_module_path(from_dir, &module_path)?;
4541    let raw_export = node_text(node, source);
4542
4543    if let Some(source_symbol) = reexport_clause_source_symbol(&raw_export, symbol_name) {
4544        return resolve_reexported_symbol_inner(
4545            &target_file,
4546            &source_symbol,
4547            file_exports_symbol,
4548            file_default_export_symbol,
4549            visited,
4550        )
4551        .or(Some(ResolvedSymbol {
4552            file: target_file,
4553            symbol: source_symbol,
4554        }));
4555    }
4556
4557    if raw_export.contains('*') {
4558        return resolve_reexported_symbol_inner(
4559            &target_file,
4560            symbol_name,
4561            file_exports_symbol,
4562            file_default_export_symbol,
4563            visited,
4564        );
4565    }
4566
4567    None
4568}
4569
4570fn resolve_local_export_alias(source: &str, file: &Path, requested_export: &str) -> Option<String> {
4571    let lang = detect_language(file)?;
4572    let grammar = grammar_for(lang);
4573    let mut parser = Parser::new();
4574    parser.set_language(&grammar).ok()?;
4575    let tree = parser.parse(source, None)?;
4576
4577    let mut cursor = tree.root_node().walk();
4578    if !cursor.goto_first_child() {
4579        return None;
4580    }
4581
4582    loop {
4583        let node = cursor.node();
4584        if node.kind() == "export_statement" && node.child_by_field_name("source").is_none() {
4585            let raw_export = node_text(node, source);
4586            if let Some(source_symbol) =
4587                reexport_clause_source_symbol(&raw_export, requested_export)
4588            {
4589                return Some(source_symbol);
4590            }
4591        }
4592
4593        if !cursor.goto_next_sibling() {
4594            break;
4595        }
4596    }
4597
4598    None
4599}
4600
4601fn reexport_clause_source_symbol(raw_export: &str, requested_export: &str) -> Option<String> {
4602    let start = raw_export.find('{')? + 1;
4603    let end = raw_export[start..].find('}')? + start;
4604    for specifier in raw_export[start..end].split(',') {
4605        let specifier = specifier.trim();
4606        if specifier.is_empty() {
4607            continue;
4608        }
4609        let specifier = specifier.strip_prefix("type ").unwrap_or(specifier).trim();
4610        if let Some((imported, exported)) = specifier.split_once(" as ") {
4611            if exported.trim() == requested_export {
4612                return Some(imported.trim().to_string());
4613            }
4614        } else if specifier == requested_export {
4615            return Some(requested_export.to_string());
4616        }
4617    }
4618    None
4619}
4620
4621fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
4622    let raw = source[node.byte_range()].trim();
4623    let quote = raw.chars().next()?;
4624    if quote != '\'' && quote != '"' {
4625        return None;
4626    }
4627    raw.strip_prefix(quote)
4628        .and_then(|value| value.strip_suffix(quote))
4629        .map(ToOwned::to_owned)
4630}
4631
4632/// Find an index file in a directory.
4633fn find_index_file(dir: &Path) -> Option<PathBuf> {
4634    for name in JS_TS_INDEX_FILES {
4635        let p = dir.join(name);
4636        if p.is_file() {
4637            return Some(std::fs::canonicalize(&p).unwrap_or(p));
4638        }
4639    }
4640    None
4641}
4642
4643/// Resolve an aliased import: `import { foo as bar } from './utils'`
4644/// where `local_name` is "bar". Returns `(original_name, resolved_file_path)`.
4645fn resolve_aliased_import(
4646    local_name: &str,
4647    import_block: &ImportBlock,
4648    caller_dir: &Path,
4649) -> Option<(String, PathBuf)> {
4650    for imp in &import_block.imports {
4651        // Parse the raw text to find "as <alias>" patterns
4652        // This handles: import { foo as bar, baz as qux } from './mod'
4653        if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
4654            if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
4655                return Some((original, resolved_path));
4656            }
4657        }
4658    }
4659    None
4660}
4661
4662/// Parse import raw text to find the original name for an alias.
4663/// Given raw text like `import { foo as bar, baz } from './utils'` and
4664/// local_name "bar", returns Some("foo").
4665fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
4666    // Look for pattern: <original> as <alias>
4667    // This is a simple text-based search; handles the common TS/JS pattern
4668    let search = format!(" as {}", local_name);
4669    if let Some(pos) = raw_import.find(&search) {
4670        // Walk backwards from `pos` to find the original name
4671        let before = &raw_import[..pos];
4672        // The original name is the last word-like token before " as "
4673        let original = before
4674            .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
4675            .find(|s| !s.is_empty())?;
4676        return Some(original.to_string());
4677    }
4678    None
4679}
4680
4681// ---------------------------------------------------------------------------
4682// Worktree file discovery
4683// ---------------------------------------------------------------------------
4684
4685/// Walk project files respecting .gitignore, excluding common non-source dirs.
4686///
4687/// Returns an iterator of file paths for supported source file types.
4688pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
4689    use ignore::WalkBuilder;
4690
4691    let walker = WalkBuilder::new(root)
4692        .hidden(true)         // skip hidden files/dirs
4693        .git_ignore(true)     // respect .gitignore
4694        .git_global(true)     // respect global gitignore
4695        .git_exclude(true)    // respect .git/info/exclude
4696        .add_custom_ignore_filename(".aftignore") // AFT-specific ignores (e.g. submodules)
4697        .filter_entry(|entry| {
4698            let name = entry.file_name().to_string_lossy();
4699            // Always exclude these directories regardless of .gitignore
4700            if entry.file_type().map_or(false, |ft| ft.is_dir()) {
4701                return !matches!(
4702                    name.as_ref(),
4703                    "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
4704                        | ".tox" | "dist" | "build"
4705                );
4706            }
4707            true
4708        })
4709        .build();
4710
4711    walker
4712        .filter_map(|entry| entry.ok())
4713        .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
4714        .filter(|entry| detect_language(entry.path()).is_some())
4715        .map(|entry| entry.into_path())
4716}
4717
4718// ---------------------------------------------------------------------------
4719// Tests
4720// ---------------------------------------------------------------------------
4721
4722#[cfg(test)]
4723mod tests {
4724    use super::*;
4725    use std::fs;
4726    use tempfile::TempDir;
4727
4728    #[test]
4729    fn symbol_metadata_for_recovers_scoped_method_by_bare_name() {
4730        // exported_symbols carries the bare name; symbol_metadata is keyed by
4731        // scoped identity (impl method). A plain .get(bare) misses and would
4732        // force the degraded unknown/line-1 fallback. symbol_metadata_for must
4733        // recover the scoped entry via unqualified-name match.
4734        let mut symbol_metadata = HashMap::new();
4735        symbol_metadata.insert(
4736            "BackupStore::total_disk_bytes".to_string(),
4737            SymbolMeta {
4738                kind: SymbolKind::Method,
4739                exported: true,
4740                signature: None,
4741                line: 703,
4742                range: Range {
4743                    start_line: 702,
4744                    start_col: 0,
4745                    end_line: 705,
4746                    end_col: 0,
4747                },
4748            },
4749        );
4750        let file_data = FileCallData {
4751            calls_by_symbol: HashMap::new(),
4752            exported_symbols: vec!["total_disk_bytes".to_string()],
4753            symbol_metadata,
4754            default_export_symbol: None,
4755            import_block: ImportBlock::empty(),
4756            lang: LangId::Rust,
4757        };
4758
4759        let meta = file_data
4760            .symbol_metadata_for("total_disk_bytes")
4761            .expect("scoped method recovered by bare name");
4762        assert_eq!(meta.kind, SymbolKind::Method);
4763        assert_eq!(
4764            meta.line, 703,
4765            "real declaration line, not the line-1 fallback"
4766        );
4767
4768        // A genuinely-absent symbol still returns None (no false recovery).
4769        assert!(file_data.symbol_metadata_for("does_not_exist").is_none());
4770    }
4771
4772    /// Create a temp directory with TypeScript files for testing.
4773    fn setup_ts_project() -> TempDir {
4774        let dir = TempDir::new().unwrap();
4775
4776        // main.ts: imports from utils and calls functions
4777        fs::write(
4778            dir.path().join("main.ts"),
4779            r#"import { helper, compute } from './utils';
4780import * as math from './math';
4781
4782export function main() {
4783    const a = helper(1);
4784    const b = compute(a, 2);
4785    const c = math.add(a, b);
4786    return c;
4787}
4788"#,
4789        )
4790        .unwrap();
4791
4792        // utils.ts: defines helper and compute, imports from helpers
4793        fs::write(
4794            dir.path().join("utils.ts"),
4795            r#"import { double } from './helpers';
4796
4797export function helper(x: number): number {
4798    return double(x);
4799}
4800
4801export function compute(a: number, b: number): number {
4802    return a + b;
4803}
4804"#,
4805        )
4806        .unwrap();
4807
4808        // helpers.ts: defines double
4809        fs::write(
4810            dir.path().join("helpers.ts"),
4811            r#"export function double(x: number): number {
4812    return x * 2;
4813}
4814
4815export function triple(x: number): number {
4816    return x * 3;
4817}
4818"#,
4819        )
4820        .unwrap();
4821
4822        // math.ts: defines add (for namespace import test)
4823        fs::write(
4824            dir.path().join("math.ts"),
4825            r#"export function add(a: number, b: number): number {
4826    return a + b;
4827}
4828
4829export function subtract(a: number, b: number): number {
4830    return a - b;
4831}
4832"#,
4833        )
4834        .unwrap();
4835
4836        dir
4837    }
4838
4839    /// Create a project with import aliasing.
4840    fn setup_alias_project() -> TempDir {
4841        let dir = TempDir::new().unwrap();
4842
4843        fs::write(
4844            dir.path().join("main.ts"),
4845            r#"import { helper as h } from './utils';
4846
4847export function main() {
4848    return h(42);
4849}
4850"#,
4851        )
4852        .unwrap();
4853
4854        fs::write(
4855            dir.path().join("utils.ts"),
4856            r#"export function helper(x: number): number {
4857    return x + 1;
4858}
4859"#,
4860        )
4861        .unwrap();
4862
4863        dir
4864    }
4865
4866    /// Create a project with a cycle: A → B → A.
4867    fn setup_cycle_project() -> TempDir {
4868        let dir = TempDir::new().unwrap();
4869
4870        fs::write(
4871            dir.path().join("a.ts"),
4872            r#"import { funcB } from './b';
4873
4874export function funcA() {
4875    return funcB();
4876}
4877"#,
4878        )
4879        .unwrap();
4880
4881        fs::write(
4882            dir.path().join("b.ts"),
4883            r#"import { funcA } from './a';
4884
4885export function funcB() {
4886    return funcA();
4887}
4888"#,
4889        )
4890        .unwrap();
4891
4892        dir
4893    }
4894
4895    // --- Single-file call extraction ---
4896
4897    #[test]
4898    fn callgraph_single_file_call_extraction() {
4899        let dir = setup_ts_project();
4900        let mut graph = CallGraph::new(dir.path().to_path_buf());
4901
4902        let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
4903        let main_calls = &file_data.calls_by_symbol["main"];
4904
4905        let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
4906        assert!(
4907            callee_names.contains(&"helper"),
4908            "main should call helper, got: {:?}",
4909            callee_names
4910        );
4911        assert!(
4912            callee_names.contains(&"compute"),
4913            "main should call compute, got: {:?}",
4914            callee_names
4915        );
4916        assert!(
4917            callee_names.contains(&"add"),
4918            "main should call math.add (short name: add), got: {:?}",
4919            callee_names
4920        );
4921    }
4922
4923    #[test]
4924    fn callgraph_file_data_has_exports() {
4925        let dir = setup_ts_project();
4926        let mut graph = CallGraph::new(dir.path().to_path_buf());
4927
4928        let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
4929        assert!(
4930            file_data.exported_symbols.contains(&"helper".to_string()),
4931            "utils.ts should export helper, got: {:?}",
4932            file_data.exported_symbols
4933        );
4934        assert!(
4935            file_data.exported_symbols.contains(&"compute".to_string()),
4936            "utils.ts should export compute, got: {:?}",
4937            file_data.exported_symbols
4938        );
4939    }
4940
4941    // --- Cross-file resolution ---
4942
4943    #[test]
4944    fn callgraph_resolve_direct_import() {
4945        let dir = setup_ts_project();
4946        let mut graph = CallGraph::new(dir.path().to_path_buf());
4947
4948        let main_path = dir.path().join("main.ts");
4949        let file_data = graph.build_file(&main_path).unwrap();
4950        let import_block = file_data.import_block.clone();
4951
4952        let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
4953        match edge {
4954            EdgeResolution::Resolved { file, symbol } => {
4955                assert!(
4956                    file.ends_with("utils.ts"),
4957                    "helper should resolve to utils.ts, got: {:?}",
4958                    file
4959                );
4960                assert_eq!(symbol, "helper");
4961            }
4962            EdgeResolution::Unresolved { callee_name } => {
4963                panic!("Expected resolved, got unresolved: {}", callee_name);
4964            }
4965        }
4966    }
4967
4968    #[test]
4969    fn callgraph_resolve_namespace_import() {
4970        let dir = setup_ts_project();
4971        let mut graph = CallGraph::new(dir.path().to_path_buf());
4972
4973        let main_path = dir.path().join("main.ts");
4974        let file_data = graph.build_file(&main_path).unwrap();
4975        let import_block = file_data.import_block.clone();
4976
4977        let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
4978        match edge {
4979            EdgeResolution::Resolved { file, symbol } => {
4980                assert!(
4981                    file.ends_with("math.ts"),
4982                    "math.add should resolve to math.ts, got: {:?}",
4983                    file
4984                );
4985                assert_eq!(symbol, "add");
4986            }
4987            EdgeResolution::Unresolved { callee_name } => {
4988                panic!("Expected resolved, got unresolved: {}", callee_name);
4989            }
4990        }
4991    }
4992
4993    #[test]
4994    fn callgraph_resolve_aliased_import() {
4995        let dir = setup_alias_project();
4996        let mut graph = CallGraph::new(dir.path().to_path_buf());
4997
4998        let main_path = dir.path().join("main.ts");
4999        let file_data = graph.build_file(&main_path).unwrap();
5000        let import_block = file_data.import_block.clone();
5001
5002        let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
5003        match edge {
5004            EdgeResolution::Resolved { file, symbol } => {
5005                assert!(
5006                    file.ends_with("utils.ts"),
5007                    "h (alias for helper) should resolve to utils.ts, got: {:?}",
5008                    file
5009                );
5010                assert_eq!(symbol, "helper");
5011            }
5012            EdgeResolution::Unresolved { callee_name } => {
5013                panic!("Expected resolved, got unresolved: {}", callee_name);
5014            }
5015        }
5016    }
5017
5018    #[test]
5019    fn callgraph_unresolved_edge_marked() {
5020        let dir = setup_ts_project();
5021        let mut graph = CallGraph::new(dir.path().to_path_buf());
5022
5023        let main_path = dir.path().join("main.ts");
5024        let file_data = graph.build_file(&main_path).unwrap();
5025        let import_block = file_data.import_block.clone();
5026
5027        let edge =
5028            graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
5029        assert_eq!(
5030            edge,
5031            EdgeResolution::Unresolved {
5032                callee_name: "unknownFunc".to_string()
5033            },
5034            "Unknown callee should be unresolved"
5035        );
5036    }
5037
5038    // --- Cycle detection ---
5039
5040    #[test]
5041    fn callgraph_cycle_detection_stops() {
5042        let dir = setup_cycle_project();
5043        let mut graph = CallGraph::new(dir.path().to_path_buf());
5044
5045        // This should NOT infinite loop
5046        let tree = graph
5047            .forward_tree(&dir.path().join("a.ts"), "funcA", 10)
5048            .unwrap();
5049
5050        assert_eq!(tree.name, "funcA");
5051        assert!(tree.resolved);
5052
5053        // funcA calls funcB, funcB calls funcA (cycle), so the depth should be bounded
5054        // The tree should have children but not infinitely deep
5055        fn count_depth(node: &CallTreeNode) -> usize {
5056            if node.children.is_empty() {
5057                1
5058            } else {
5059                1 + node.children.iter().map(count_depth).max().unwrap_or(0)
5060            }
5061        }
5062
5063        let depth = count_depth(&tree);
5064        assert!(
5065            depth <= 4,
5066            "Cycle should be detected and bounded, depth was: {}",
5067            depth
5068        );
5069    }
5070
5071    // --- Depth limiting ---
5072
5073    #[test]
5074    fn callgraph_depth_limit_truncates() {
5075        let dir = setup_ts_project();
5076        let mut graph = CallGraph::new(dir.path().to_path_buf());
5077
5078        // main → helper → double, main → compute
5079        // With depth 1, we should see direct callees but not their children
5080        let tree = graph
5081            .forward_tree(&dir.path().join("main.ts"), "main", 1)
5082            .unwrap();
5083
5084        assert_eq!(tree.name, "main");
5085        assert!(tree.depth_limited, "depth limit should be reported");
5086        assert!(
5087            tree.truncated > 0,
5088            "truncated edge count should be reported"
5089        );
5090
5091        // At depth 1, children should exist (direct calls) but their children should be empty
5092        for child in &tree.children {
5093            assert!(
5094                child.children.is_empty(),
5095                "At depth 1, child '{}' should have no children, got {:?}",
5096                child.name,
5097                child.children.len()
5098            );
5099        }
5100    }
5101
5102    #[test]
5103    fn callgraph_depth_zero_no_children() {
5104        let dir = setup_ts_project();
5105        let mut graph = CallGraph::new(dir.path().to_path_buf());
5106
5107        let tree = graph
5108            .forward_tree(&dir.path().join("main.ts"), "main", 0)
5109            .unwrap();
5110
5111        assert_eq!(tree.name, "main");
5112        assert!(
5113            tree.children.is_empty(),
5114            "At depth 0, should have no children"
5115        );
5116    }
5117
5118    // --- Forward tree cross-file ---
5119
5120    #[test]
5121    fn callgraph_forward_tree_cross_file() {
5122        let dir = setup_ts_project();
5123        let mut graph = CallGraph::new(dir.path().to_path_buf());
5124
5125        // main → helper (in utils.ts) → double (in helpers.ts)
5126        let tree = graph
5127            .forward_tree(&dir.path().join("main.ts"), "main", 5)
5128            .unwrap();
5129
5130        assert_eq!(tree.name, "main");
5131        assert!(tree.resolved);
5132
5133        // Find the helper child
5134        let helper_child = tree.children.iter().find(|c| c.name == "helper");
5135        assert!(
5136            helper_child.is_some(),
5137            "main should have helper as child, children: {:?}",
5138            tree.children.iter().map(|c| &c.name).collect::<Vec<_>>()
5139        );
5140
5141        let helper = helper_child.unwrap();
5142        assert!(
5143            helper.file.ends_with("utils.ts") || helper.file == "utils.ts",
5144            "helper should be in utils.ts, got: {}",
5145            helper.file
5146        );
5147
5148        // helper should call double (in helpers.ts)
5149        let double_child = helper.children.iter().find(|c| c.name == "double");
5150        assert!(
5151            double_child.is_some(),
5152            "helper should call double, children: {:?}",
5153            helper.children.iter().map(|c| &c.name).collect::<Vec<_>>()
5154        );
5155
5156        let double = double_child.unwrap();
5157        assert!(
5158            double.file.ends_with("helpers.ts") || double.file == "helpers.ts",
5159            "double should be in helpers.ts, got: {}",
5160            double.file
5161        );
5162    }
5163
5164    // --- Worktree walker ---
5165
5166    #[test]
5167    fn callgraph_walker_excludes_gitignored() {
5168        let dir = TempDir::new().unwrap();
5169
5170        // Create a .gitignore
5171        fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
5172
5173        // Create files
5174        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
5175        fs::create_dir(dir.path().join("ignored_dir")).unwrap();
5176        fs::write(
5177            dir.path().join("ignored_dir").join("secret.ts"),
5178            "export function secret() {}",
5179        )
5180        .unwrap();
5181
5182        // Also create node_modules (should always be excluded)
5183        fs::create_dir(dir.path().join("node_modules")).unwrap();
5184        fs::write(
5185            dir.path().join("node_modules").join("dep.ts"),
5186            "export function dep() {}",
5187        )
5188        .unwrap();
5189
5190        // Init git repo for .gitignore to work
5191        std::process::Command::new("git")
5192            .args(["init"])
5193            .current_dir(dir.path())
5194            .output()
5195            .unwrap();
5196
5197        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
5198        let file_names: Vec<String> = files
5199            .iter()
5200            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
5201            .collect();
5202
5203        assert!(
5204            file_names.contains(&"main.ts".to_string()),
5205            "Should include main.ts, got: {:?}",
5206            file_names
5207        );
5208        assert!(
5209            !file_names.contains(&"secret.ts".to_string()),
5210            "Should exclude gitignored secret.ts, got: {:?}",
5211            file_names
5212        );
5213        assert!(
5214            !file_names.contains(&"dep.ts".to_string()),
5215            "Should exclude node_modules, got: {:?}",
5216            file_names
5217        );
5218    }
5219
5220    #[test]
5221    fn callgraph_walker_excludes_aftignored() {
5222        let dir = TempDir::new().unwrap();
5223
5224        // .aftignore is honored without a git repo (custom ignore file).
5225        fs::write(dir.path().join(".aftignore"), "vendored/\n").unwrap();
5226        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
5227        fs::create_dir(dir.path().join("vendored")).unwrap();
5228        fs::write(
5229            dir.path().join("vendored").join("sub.ts"),
5230            "export function sub() {}",
5231        )
5232        .unwrap();
5233
5234        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
5235        let file_names: Vec<String> = files
5236            .iter()
5237            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
5238            .collect();
5239
5240        assert!(
5241            file_names.contains(&"main.ts".to_string()),
5242            "Should include main.ts, got: {:?}",
5243            file_names
5244        );
5245        assert!(
5246            !file_names.contains(&"sub.ts".to_string()),
5247            "Should exclude .aftignored sub.ts, got: {:?}",
5248            file_names
5249        );
5250    }
5251
5252    #[test]
5253    fn callgraph_walker_only_source_files() {
5254        let dir = TempDir::new().unwrap();
5255
5256        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
5257        fs::write(dir.path().join("module.mts"), "export function esm() {}").unwrap();
5258        fs::write(dir.path().join("common.cts"), "export function cjs() {}").unwrap();
5259        fs::write(
5260            dir.path().join("runtime.mjs"),
5261            "export function runtime() {}",
5262        )
5263        .unwrap();
5264        fs::write(
5265            dir.path().join("legacy.cjs"),
5266            "exports.legacy = function() {};",
5267        )
5268        .unwrap();
5269        fs::write(dir.path().join("types.pyi"), "def typed() -> None: ...").unwrap();
5270        fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
5271        fs::write(dir.path().join("data.json"), "{}").unwrap();
5272
5273        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
5274        let file_names: Vec<String> = files
5275            .iter()
5276            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
5277            .collect();
5278
5279        assert!(file_names.contains(&"main.ts".to_string()));
5280        for modern_ext_file in [
5281            "module.mts",
5282            "common.cts",
5283            "runtime.mjs",
5284            "legacy.cjs",
5285            "types.pyi",
5286        ] {
5287            assert!(
5288                file_names.contains(&modern_ext_file.to_string()),
5289                "walker should include {modern_ext_file}, got: {:?}",
5290                file_names
5291            );
5292        }
5293        assert!(
5294            file_names.contains(&"readme.md".to_string()),
5295            "Markdown is now a supported source language"
5296        );
5297        assert!(
5298            file_names.contains(&"data.json".to_string()),
5299            "JSON is now a supported source language"
5300        );
5301    }
5302
5303    // --- find_alias_original ---
5304
5305    #[test]
5306    fn callgraph_find_alias_original_simple() {
5307        let raw = "import { foo as bar } from './utils';";
5308        assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
5309    }
5310
5311    #[test]
5312    fn callgraph_find_alias_original_multiple() {
5313        let raw = "import { foo as bar, baz as qux } from './utils';";
5314        assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
5315        assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
5316    }
5317
5318    #[test]
5319    fn callgraph_find_alias_no_match() {
5320        let raw = "import { foo } from './utils';";
5321        assert_eq!(find_alias_original(raw, "foo"), None);
5322    }
5323
5324    // --- Reverse callers ---
5325
5326    #[test]
5327    fn callgraph_callers_of_direct() {
5328        let dir = setup_ts_project();
5329        let mut graph = CallGraph::new(dir.path().to_path_buf());
5330
5331        // helpers.ts:double is called by utils.ts:helper
5332        let result = graph
5333            .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
5334            .unwrap();
5335
5336        assert_eq!(result.symbol, "double");
5337        assert!(result.total_callers > 0, "double should have callers");
5338        assert!(result.scanned_files > 0, "should have scanned files");
5339
5340        // Find the caller from utils.ts
5341        let utils_group = result.callers.iter().find(|g| g.file.contains("utils.ts"));
5342        assert!(
5343            utils_group.is_some(),
5344            "double should be called from utils.ts, groups: {:?}",
5345            result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
5346        );
5347
5348        let group = utils_group.unwrap();
5349        let helper_caller = group.callers.iter().find(|c| c.symbol == "helper");
5350        assert!(
5351            helper_caller.is_some(),
5352            "double should be called by helper, callers: {:?}",
5353            group.callers.iter().map(|c| &c.symbol).collect::<Vec<_>>()
5354        );
5355    }
5356
5357    #[test]
5358    fn callgraph_callers_of_no_callers() {
5359        let dir = setup_ts_project();
5360        let mut graph = CallGraph::new(dir.path().to_path_buf());
5361
5362        // main.ts:main is the entry point — nothing calls it
5363        let result = graph
5364            .callers_of(&dir.path().join("main.ts"), "main", 1, usize::MAX)
5365            .unwrap();
5366
5367        assert_eq!(result.symbol, "main");
5368        assert_eq!(result.total_callers, 0, "main should have no callers");
5369        assert!(result.callers.is_empty());
5370    }
5371
5372    #[test]
5373    fn callgraph_callers_recursive_depth() {
5374        let dir = setup_ts_project();
5375        let mut graph = CallGraph::new(dir.path().to_path_buf());
5376
5377        // helpers.ts:double is called by utils.ts:helper
5378        // utils.ts:helper is called by main.ts:main
5379        // With depth=2, we should see both direct and transitive callers
5380        let result = graph
5381            .callers_of(&dir.path().join("helpers.ts"), "double", 2, usize::MAX)
5382            .unwrap();
5383
5384        assert!(
5385            result.total_callers >= 2,
5386            "with depth 2, double should have >= 2 callers (direct + transitive), got {}",
5387            result.total_callers
5388        );
5389
5390        // Should include caller from main.ts (transitive: main → helper → double)
5391        let main_group = result.callers.iter().find(|g| g.file.contains("main.ts"));
5392        assert!(
5393            main_group.is_some(),
5394            "recursive callers should include main.ts, groups: {:?}",
5395            result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
5396        );
5397    }
5398
5399    #[test]
5400    fn callgraph_invalidate_file_clears_reverse_index() {
5401        let dir = setup_ts_project();
5402        let mut graph = CallGraph::new(dir.path().to_path_buf());
5403
5404        // Build callers to populate the reverse index
5405        let _ = graph
5406            .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
5407            .unwrap();
5408        assert!(
5409            graph.reverse_index.is_some(),
5410            "reverse index should be built"
5411        );
5412
5413        // Invalidate a file
5414        graph.invalidate_file(&dir.path().join("utils.ts"));
5415
5416        // Reverse index should be cleared
5417        assert!(
5418            graph.reverse_index.is_none(),
5419            "invalidate_file should clear reverse index"
5420        );
5421        // Data cache for the file should be cleared
5422        let canon = std::fs::canonicalize(dir.path().join("utils.ts")).unwrap();
5423        assert!(
5424            !graph.data.contains_key(&canon),
5425            "invalidate_file should remove file from data cache"
5426        );
5427        // Project files should be cleared
5428        assert!(
5429            graph.project_files.is_none(),
5430            "invalidate_file should clear project_files"
5431        );
5432    }
5433
5434    // --- is_entry_point ---
5435
5436    #[test]
5437    fn is_entry_point_exported_function() {
5438        assert!(is_entry_point(
5439            "handleRequest",
5440            &SymbolKind::Function,
5441            true,
5442            LangId::TypeScript
5443        ));
5444    }
5445
5446    #[test]
5447    fn is_entry_point_exported_method_is_not_entry() {
5448        // Methods are class members, not standalone entry points
5449        assert!(!is_entry_point(
5450            "handleRequest",
5451            &SymbolKind::Method,
5452            true,
5453            LangId::TypeScript
5454        ));
5455    }
5456
5457    #[test]
5458    fn is_entry_point_main_init_patterns() {
5459        for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
5460            assert!(
5461                is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
5462                "{} should be an entry point",
5463                name
5464            );
5465        }
5466    }
5467
5468    #[test]
5469    fn is_entry_point_test_patterns_ts() {
5470        assert!(is_entry_point(
5471            "describe",
5472            &SymbolKind::Function,
5473            false,
5474            LangId::TypeScript
5475        ));
5476        assert!(is_entry_point(
5477            "it",
5478            &SymbolKind::Function,
5479            false,
5480            LangId::TypeScript
5481        ));
5482        assert!(is_entry_point(
5483            "test",
5484            &SymbolKind::Function,
5485            false,
5486            LangId::TypeScript
5487        ));
5488        assert!(is_entry_point(
5489            "testValidation",
5490            &SymbolKind::Function,
5491            false,
5492            LangId::TypeScript
5493        ));
5494        assert!(is_entry_point(
5495            "specHelper",
5496            &SymbolKind::Function,
5497            false,
5498            LangId::TypeScript
5499        ));
5500    }
5501
5502    #[test]
5503    fn is_entry_point_test_patterns_python() {
5504        assert!(is_entry_point(
5505            "test_login",
5506            &SymbolKind::Function,
5507            false,
5508            LangId::Python
5509        ));
5510        assert!(is_entry_point(
5511            "setUp",
5512            &SymbolKind::Function,
5513            false,
5514            LangId::Python
5515        ));
5516        assert!(is_entry_point(
5517            "tearDown",
5518            &SymbolKind::Function,
5519            false,
5520            LangId::Python
5521        ));
5522        // "testSomething" should NOT match Python (needs test_ prefix)
5523        assert!(!is_entry_point(
5524            "testSomething",
5525            &SymbolKind::Function,
5526            false,
5527            LangId::Python
5528        ));
5529    }
5530
5531    #[test]
5532    fn is_entry_point_test_patterns_rust() {
5533        assert!(is_entry_point(
5534            "test_parse",
5535            &SymbolKind::Function,
5536            false,
5537            LangId::Rust
5538        ));
5539        assert!(!is_entry_point(
5540            "TestSomething",
5541            &SymbolKind::Function,
5542            false,
5543            LangId::Rust
5544        ));
5545    }
5546
5547    #[test]
5548    fn is_entry_point_test_patterns_go() {
5549        assert!(is_entry_point(
5550            "TestParsing",
5551            &SymbolKind::Function,
5552            false,
5553            LangId::Go
5554        ));
5555        // lowercase test should NOT match Go (needs uppercase Test prefix)
5556        assert!(!is_entry_point(
5557            "testParsing",
5558            &SymbolKind::Function,
5559            false,
5560            LangId::Go
5561        ));
5562    }
5563
5564    #[test]
5565    fn is_entry_point_non_exported_non_main_is_not_entry() {
5566        assert!(!is_entry_point(
5567            "helperUtil",
5568            &SymbolKind::Function,
5569            false,
5570            LangId::TypeScript
5571        ));
5572    }
5573
5574    // --- symbol_metadata ---
5575
5576    #[test]
5577    fn callgraph_symbol_metadata_populated() {
5578        let dir = setup_ts_project();
5579        let mut graph = CallGraph::new(dir.path().to_path_buf());
5580
5581        let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
5582        assert!(
5583            file_data.symbol_metadata.contains_key("helper"),
5584            "symbol_metadata should contain helper"
5585        );
5586        let meta = &file_data.symbol_metadata["helper"];
5587        assert_eq!(meta.kind, SymbolKind::Function);
5588        assert!(meta.exported, "helper should be exported");
5589    }
5590
5591    // --- trace_to ---
5592
5593    /// Setup a multi-path project for trace_to tests.
5594    ///
5595    /// Structure:
5596    ///   main.ts: exported main() → processData (from utils)
5597    ///   service.ts: exported handleRequest() → processData (from utils)
5598    ///   utils.ts: exported processData() → validate (from helpers)
5599    ///   helpers.ts: exported validate() → checkFormat (local, not exported)
5600    ///   test_helpers.ts: testValidation() → validate (from helpers)
5601    ///
5602    /// checkFormat should have 3 paths:
5603    ///   main → processData → validate → checkFormat
5604    ///   handleRequest → processData → validate → checkFormat
5605    ///   testValidation → validate → checkFormat
5606    fn setup_trace_project() -> TempDir {
5607        let dir = TempDir::new().unwrap();
5608
5609        fs::write(
5610            dir.path().join("main.ts"),
5611            r#"import { processData } from './utils';
5612
5613export function main() {
5614    const result = processData("hello");
5615    return result;
5616}
5617"#,
5618        )
5619        .unwrap();
5620
5621        fs::write(
5622            dir.path().join("service.ts"),
5623            r#"import { processData } from './utils';
5624
5625export function handleRequest(input: string): string {
5626    return processData(input);
5627}
5628"#,
5629        )
5630        .unwrap();
5631
5632        fs::write(
5633            dir.path().join("utils.ts"),
5634            r#"import { validate } from './helpers';
5635
5636export function processData(input: string): string {
5637    const valid = validate(input);
5638    if (!valid) {
5639        throw new Error("invalid input");
5640    }
5641    return input.toUpperCase();
5642}
5643"#,
5644        )
5645        .unwrap();
5646
5647        fs::write(
5648            dir.path().join("helpers.ts"),
5649            r#"export function validate(input: string): boolean {
5650    return checkFormat(input);
5651}
5652
5653function checkFormat(input: string): boolean {
5654    return input.length > 0 && /^[a-zA-Z]+$/.test(input);
5655}
5656"#,
5657        )
5658        .unwrap();
5659
5660        fs::write(
5661            dir.path().join("test_helpers.ts"),
5662            r#"import { validate } from './helpers';
5663
5664function testValidation() {
5665    const result = validate("hello");
5666    console.log(result);
5667}
5668"#,
5669        )
5670        .unwrap();
5671
5672        // git init so the walker works
5673        std::process::Command::new("git")
5674            .args(["init"])
5675            .current_dir(dir.path())
5676            .output()
5677            .unwrap();
5678
5679        dir
5680    }
5681
5682    #[test]
5683    fn trace_to_multi_path() {
5684        let dir = setup_trace_project();
5685        let mut graph = CallGraph::new(dir.path().to_path_buf());
5686
5687        let result = graph
5688            .trace_to(
5689                &dir.path().join("helpers.ts"),
5690                "checkFormat",
5691                10,
5692                usize::MAX,
5693            )
5694            .unwrap();
5695
5696        assert_eq!(result.target_symbol, "checkFormat");
5697        assert!(
5698            result.total_paths >= 2,
5699            "checkFormat should have at least 2 paths, got {} (paths: {:?})",
5700            result.total_paths,
5701            result
5702                .paths
5703                .iter()
5704                .map(|p| p.hops.iter().map(|h| h.symbol.as_str()).collect::<Vec<_>>())
5705                .collect::<Vec<_>>()
5706        );
5707
5708        // Check that paths are top-down: entry point first, target last
5709        for path in &result.paths {
5710            assert!(
5711                path.hops.first().unwrap().is_entry_point,
5712                "First hop should be an entry point, got: {}",
5713                path.hops.first().unwrap().symbol
5714            );
5715            assert_eq!(
5716                path.hops.last().unwrap().symbol,
5717                "checkFormat",
5718                "Last hop should be checkFormat"
5719            );
5720        }
5721
5722        // Verify entry_points_found > 0
5723        assert!(
5724            result.entry_points_found >= 2,
5725            "should find at least 2 entry points, got {}",
5726            result.entry_points_found
5727        );
5728    }
5729
5730    #[test]
5731    fn trace_to_single_path() {
5732        let dir = setup_trace_project();
5733        let mut graph = CallGraph::new(dir.path().to_path_buf());
5734
5735        // validate is called from processData, testValidation
5736        // processData is called from main, handleRequest
5737        // So validate has paths: main→processData→validate, handleRequest→processData→validate, testValidation→validate
5738        let result = graph
5739            .trace_to(&dir.path().join("helpers.ts"), "validate", 10, usize::MAX)
5740            .unwrap();
5741
5742        assert_eq!(result.target_symbol, "validate");
5743        assert!(
5744            result.total_paths >= 2,
5745            "validate should have at least 2 paths, got {}",
5746            result.total_paths
5747        );
5748    }
5749
5750    #[test]
5751    fn trace_to_cycle_detection() {
5752        let dir = setup_cycle_project();
5753        let mut graph = CallGraph::new(dir.path().to_path_buf());
5754
5755        // funcA ↔ funcB cycle — should terminate
5756        let result = graph
5757            .trace_to(&dir.path().join("a.ts"), "funcA", 10, usize::MAX)
5758            .unwrap();
5759
5760        // Should not hang — the fact we got here means cycle detection works
5761        assert_eq!(result.target_symbol, "funcA");
5762    }
5763
5764    #[test]
5765    fn trace_to_depth_limit() {
5766        let dir = setup_trace_project();
5767        let mut graph = CallGraph::new(dir.path().to_path_buf());
5768
5769        // With max_depth=1, should not be able to reach entry points that are 3+ hops away
5770        let result = graph
5771            .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 1, usize::MAX)
5772            .unwrap();
5773
5774        // testValidation→validate→checkFormat is 2 hops, which requires depth >= 2
5775        // main→processData→validate→checkFormat is 3 hops, which requires depth >= 3
5776        // With depth=1, most paths should be truncated
5777        assert_eq!(result.target_symbol, "checkFormat");
5778
5779        // The shallow result should have fewer paths than the deep one
5780        let deep_result = graph
5781            .trace_to(
5782                &dir.path().join("helpers.ts"),
5783                "checkFormat",
5784                10,
5785                usize::MAX,
5786            )
5787            .unwrap();
5788
5789        assert!(
5790            result.total_paths <= deep_result.total_paths,
5791            "shallow trace should find <= paths compared to deep: {} vs {}",
5792            result.total_paths,
5793            deep_result.total_paths
5794        );
5795    }
5796
5797    #[test]
5798    fn trace_to_entry_point_target() {
5799        let dir = setup_trace_project();
5800        let mut graph = CallGraph::new(dir.path().to_path_buf());
5801
5802        // main is itself an entry point — should return a single trivial path
5803        let result = graph
5804            .trace_to(&dir.path().join("main.ts"), "main", 10, usize::MAX)
5805            .unwrap();
5806
5807        assert_eq!(result.target_symbol, "main");
5808        assert!(
5809            result.total_paths >= 1,
5810            "main should have at least 1 path (itself), got {}",
5811            result.total_paths
5812        );
5813        // Check the trivial path has just one hop
5814        let trivial = result.paths.iter().find(|p| p.hops.len() == 1);
5815        assert!(
5816            trivial.is_some(),
5817            "should have a trivial path with just the entry point itself"
5818        );
5819    }
5820
5821    #[test]
5822    fn namespace_import_follows_barrel_reexport_and_rejects_private_member() {
5823        let dir = TempDir::new().unwrap();
5824        fs::write(
5825            dir.path().join("main.ts"),
5826            r#"import * as lib from './index';
5827
5828export function main() {
5829    lib.helper();
5830    lib.hidden();
5831}
5832"#,
5833        )
5834        .unwrap();
5835        fs::write(
5836            dir.path().join("index.ts"),
5837            "export { helper } from './utils';\n",
5838        )
5839        .unwrap();
5840        fs::write(
5841            dir.path().join("utils.ts"),
5842            r#"export function helper() {}
5843function hidden() {}
5844"#,
5845        )
5846        .unwrap();
5847
5848        let mut graph = CallGraph::new(dir.path().to_path_buf());
5849        let main_path = dir.path().join("main.ts");
5850        let import_block = graph.build_file(&main_path).unwrap().import_block.clone();
5851
5852        let helper =
5853            graph.resolve_cross_file_edge("lib.helper", "helper", &main_path, &import_block);
5854        match helper {
5855            EdgeResolution::Resolved { file, symbol } => {
5856                assert!(
5857                    file.ends_with("utils.ts"),
5858                    "helper should resolve through barrel: {file:?}"
5859                );
5860                assert_eq!(symbol, "helper");
5861            }
5862            other => panic!("expected helper to resolve through barrel, got {other:?}"),
5863        }
5864
5865        let hidden =
5866            graph.resolve_cross_file_edge("lib.hidden", "hidden", &main_path, &import_block);
5867        assert_eq!(
5868            hidden,
5869            EdgeResolution::Unresolved {
5870                callee_name: "hidden".to_string()
5871            }
5872        );
5873    }
5874
5875    #[test]
5876    fn workspace_package_resolution_prefers_modern_ts_source_extensions() {
5877        let dir = TempDir::new().unwrap();
5878        fs::write(
5879            dir.path().join("package.json"),
5880            r#"{"workspaces":["packages/*"]}"#,
5881        )
5882        .unwrap();
5883        let package_dir = dir.path().join("packages/lib");
5884        fs::create_dir_all(package_dir.join("src")).unwrap();
5885        fs::create_dir_all(package_dir.join("dist")).unwrap();
5886        fs::write(
5887            package_dir.join("package.json"),
5888            r#"{"name":"@scope/lib","exports":{".":"./dist/index.mjs"}}"#,
5889        )
5890        .unwrap();
5891        fs::write(
5892            package_dir.join("src/index.mts"),
5893            "export function helper() {}\n",
5894        )
5895        .unwrap();
5896        fs::write(package_dir.join("dist/index.mjs"), "export{};\n").unwrap();
5897
5898        let resolved = resolve_module_path(dir.path(), "@scope/lib").unwrap();
5899        assert!(
5900            resolved.ends_with("src/index.mts"),
5901            "dist/index.mjs should map to src/index.mts, got {resolved:?}"
5902        );
5903    }
5904
5905    #[test]
5906    fn unresolved_member_calls_do_not_become_same_file_callers() {
5907        let dir = TempDir::new().unwrap();
5908        fs::write(
5909            dir.path().join("main.ts"),
5910            r#"function caller() {
5911    db.connect();
5912}
5913
5914function connect() {}
5915"#,
5916        )
5917        .unwrap();
5918
5919        let mut graph = CallGraph::new(dir.path().to_path_buf());
5920        let result = graph
5921            .callers_of(&dir.path().join("main.ts"), "connect", 1, usize::MAX)
5922            .unwrap();
5923
5924        assert_eq!(
5925            result.total_callers, 0,
5926            "db.connect() must not call local connect"
5927        );
5928    }
5929
5930    #[test]
5931    fn same_named_methods_use_scoped_symbol_identity() {
5932        let dir = TempDir::new().unwrap();
5933        fs::write(
5934            dir.path().join("classes.ts"),
5935            r#"class A {
5936    run() { helperA(); }
5937}
5938
5939class B {
5940    run() { helperB(); }
5941}
5942
5943function helperA() {}
5944function helperB() {}
5945"#,
5946        )
5947        .unwrap();
5948
5949        let mut graph = CallGraph::new(dir.path().to_path_buf());
5950        let path = dir.path().join("classes.ts");
5951        let data = graph.build_file(&path).unwrap();
5952
5953        assert!(
5954            data.symbol_metadata.contains_key("A::run"),
5955            "A::run metadata missing"
5956        );
5957        assert!(
5958            data.symbol_metadata.contains_key("B::run"),
5959            "B::run metadata missing"
5960        );
5961        assert!(
5962            data.calls_by_symbol["A::run"]
5963                .iter()
5964                .any(|call| call.callee_name == "helperA"),
5965            "A::run calls should not be overwritten"
5966        );
5967        assert!(
5968            data.calls_by_symbol["B::run"]
5969                .iter()
5970                .any(|call| call.callee_name == "helperB"),
5971            "B::run calls should not be overwritten"
5972        );
5973
5974        assert!(matches!(
5975            graph.resolve_symbol_query(&path, "run"),
5976            Err(AftError::AmbiguousSymbol { .. })
5977        ));
5978        assert_eq!(
5979            graph.resolve_symbol_query(&path, "A::run").unwrap(),
5980            "A::run"
5981        );
5982    }
5983
5984    #[test]
5985    fn trace_to_counts_same_named_entry_points_by_file_and_symbol() {
5986        let dir = TempDir::new().unwrap();
5987        fs::create_dir_all(dir.path().join("web")).unwrap();
5988        fs::create_dir_all(dir.path().join("cli")).unwrap();
5989        fs::write(
5990            dir.path().join("target.ts"),
5991            r#"export function target() {
5992    leaf();
5993}
5994
5995function leaf() {}
5996"#,
5997        )
5998        .unwrap();
5999        fs::write(
6000            dir.path().join("web/main.ts"),
6001            r#"import { target } from '../target';
6002
6003export function main() {
6004    target();
6005}
6006"#,
6007        )
6008        .unwrap();
6009        fs::write(
6010            dir.path().join("cli/main.ts"),
6011            r#"import { target } from '../target';
6012
6013export function main() {
6014    target();
6015}
6016"#,
6017        )
6018        .unwrap();
6019
6020        let mut graph = CallGraph::new(dir.path().to_path_buf());
6021        let result = graph
6022            .trace_to(&dir.path().join("target.ts"), "leaf", 10, usize::MAX)
6023            .unwrap();
6024
6025        assert_eq!(
6026            result.total_paths, 3,
6027            "target plus two main entry paths expected"
6028        );
6029        assert_eq!(
6030            result.entry_points_found, 3,
6031            "same-named main entry points in different files must both count"
6032        );
6033    }
6034
6035    #[test]
6036    fn callers_and_impact_report_depth_truncation() {
6037        let dir = setup_ts_project();
6038        let mut graph = CallGraph::new(dir.path().to_path_buf());
6039
6040        let callers = graph
6041            .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
6042            .unwrap();
6043        assert!(
6044            callers.depth_limited,
6045            "callers should report omitted transitive callers"
6046        );
6047        assert!(
6048            callers.truncated > 0,
6049            "callers should report truncated edge count"
6050        );
6051
6052        let impact = graph
6053            .impact(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
6054            .unwrap();
6055        assert!(
6056            impact.depth_limited,
6057            "impact should report omitted transitive callers"
6058        );
6059        assert!(
6060            impact.truncated > 0,
6061            "impact should report truncated edge count"
6062        );
6063    }
6064
6065    // --- extract_parameters ---
6066
6067    #[test]
6068    fn extract_parameters_typescript() {
6069        let params = extract_parameters(
6070            "function processData(input: string, count: number): void",
6071            LangId::TypeScript,
6072        );
6073        assert_eq!(params, vec!["input", "count"]);
6074    }
6075
6076    #[test]
6077    fn extract_parameters_typescript_optional() {
6078        let params = extract_parameters(
6079            "function fetch(url: string, options?: RequestInit): Promise<Response>",
6080            LangId::TypeScript,
6081        );
6082        assert_eq!(params, vec!["url", "options"]);
6083    }
6084
6085    #[test]
6086    fn extract_parameters_typescript_defaults() {
6087        let params = extract_parameters(
6088            "function greet(name: string, greeting: string = \"hello\"): string",
6089            LangId::TypeScript,
6090        );
6091        assert_eq!(params, vec!["name", "greeting"]);
6092    }
6093
6094    #[test]
6095    fn extract_parameters_typescript_rest() {
6096        let params = extract_parameters(
6097            "function sum(...numbers: number[]): number",
6098            LangId::TypeScript,
6099        );
6100        assert_eq!(params, vec!["numbers"]);
6101    }
6102
6103    #[test]
6104    fn extract_parameters_python_self_skipped() {
6105        let params = extract_parameters(
6106            "def process(self, data: str, count: int) -> bool",
6107            LangId::Python,
6108        );
6109        assert_eq!(params, vec!["data", "count"]);
6110    }
6111
6112    #[test]
6113    fn extract_parameters_python_no_self() {
6114        let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
6115        assert_eq!(params, vec!["input"]);
6116    }
6117
6118    #[test]
6119    fn extract_parameters_python_star_args() {
6120        let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
6121        assert_eq!(params, vec!["args", "kwargs"]);
6122    }
6123
6124    #[test]
6125    fn extract_parameters_rust_self_skipped() {
6126        let params = extract_parameters(
6127            "fn process(&self, data: &str, count: usize) -> bool",
6128            LangId::Rust,
6129        );
6130        assert_eq!(params, vec!["data", "count"]);
6131    }
6132
6133    #[test]
6134    fn extract_parameters_rust_mut_self_skipped() {
6135        let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
6136        assert_eq!(params, vec!["value"]);
6137    }
6138
6139    #[test]
6140    fn extract_parameters_rust_no_self() {
6141        let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
6142        assert_eq!(params, vec!["input"]);
6143    }
6144
6145    #[test]
6146    fn extract_parameters_rust_mut_param() {
6147        let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
6148        assert_eq!(params, vec!["buf", "len"]);
6149    }
6150
6151    #[test]
6152    fn extract_parameters_go() {
6153        let params = extract_parameters(
6154            "func ProcessData(input string, count int) error",
6155            LangId::Go,
6156        );
6157        assert_eq!(params, vec!["input", "count"]);
6158    }
6159
6160    #[test]
6161    fn extract_parameters_empty() {
6162        let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
6163        assert!(
6164            params.is_empty(),
6165            "no-arg function should return empty params"
6166        );
6167    }
6168
6169    #[test]
6170    fn extract_parameters_no_parens() {
6171        let params = extract_parameters("const x = 42", LangId::TypeScript);
6172        assert!(params.is_empty(), "no parens should return empty params");
6173    }
6174
6175    #[test]
6176    fn extract_parameters_javascript() {
6177        let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
6178        assert_eq!(params, vec!["event", "target"]);
6179    }
6180}