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