Skip to main content

aft/
callgraph.rs

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