Skip to main content

aft/
callgraph.rs

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