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::collections::{HashMap, HashSet};
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use rayon::prelude::*;
12use serde::Serialize;
13use tree_sitter::Parser;
14
15use crate::calls::extract_calls_full;
16use crate::edit::line_col_to_byte;
17use crate::error::AftError;
18use crate::imports::{self, ImportBlock};
19use crate::language::LanguageProvider;
20use crate::parser::{detect_language, grammar_for, LangId};
21use crate::symbols::{Range, SymbolKind};
22
23// ---------------------------------------------------------------------------
24// Core types
25// ---------------------------------------------------------------------------
26
27type SharedPath = Arc<PathBuf>;
28type SharedStr = Arc<str>;
29type ReverseIndex = HashMap<PathBuf, HashMap<String, Vec<IndexedCallerSite>>>;
30
31/// A single call site within a function body.
32#[derive(Debug, Clone)]
33pub struct CallSite {
34    /// The short callee name (last segment, e.g. "foo" for `utils.foo()`).
35    pub callee_name: String,
36    /// The full callee expression (e.g. "utils.foo" for `utils.foo()`).
37    pub full_callee: String,
38    /// 1-based line number of the call.
39    pub line: u32,
40    /// Byte range of the call expression in the source.
41    pub byte_start: usize,
42    pub byte_end: usize,
43}
44
45/// Per-symbol metadata for entry point detection (avoids re-parsing).
46#[derive(Debug, Clone, Serialize)]
47pub struct SymbolMeta {
48    /// The kind of symbol (function, class, method, etc).
49    pub kind: SymbolKind,
50    /// Whether this symbol is exported.
51    pub exported: bool,
52    /// Function/method signature if available.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub signature: Option<String>,
55    /// 1-based start line of the symbol.
56    pub line: u32,
57    /// 0-based source range of the symbol.
58    pub range: Range,
59}
60
61/// Per-file call data: call sites grouped by containing symbol, plus
62/// exported symbol names and parsed imports.
63#[derive(Debug, Clone)]
64pub struct FileCallData {
65    /// Map from symbol name → list of call sites within that symbol's body.
66    pub calls_by_symbol: HashMap<String, Vec<CallSite>>,
67    /// Names of exported symbols in this file.
68    pub exported_symbols: Vec<String>,
69    /// Per-symbol metadata (kind, exported, signature).
70    pub symbol_metadata: HashMap<String, SymbolMeta>,
71    /// Parsed import block for cross-file resolution.
72    pub import_block: ImportBlock,
73    /// Language of the file.
74    pub lang: LangId,
75}
76
77/// Result of resolving a cross-file call edge.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum EdgeResolution {
80    /// Successfully resolved to a specific file and symbol.
81    Resolved { file: PathBuf, symbol: String },
82    /// Could not resolve — callee name preserved for diagnostics.
83    Unresolved { callee_name: String },
84}
85
86/// A single caller site: who calls a given symbol and from where.
87#[derive(Debug, Clone, Serialize)]
88pub struct CallerSite {
89    /// File containing the caller.
90    pub caller_file: PathBuf,
91    /// Symbol that makes the call.
92    pub caller_symbol: String,
93    /// 1-based line number of the call.
94    pub line: u32,
95    /// 0-based column (byte start within file, kept for future use).
96    pub col: u32,
97    /// Whether the edge was resolved via import chain.
98    pub resolved: bool,
99}
100
101#[derive(Debug, Clone)]
102struct IndexedCallerSite {
103    caller_file: SharedPath,
104    caller_symbol: SharedStr,
105    line: u32,
106    col: u32,
107    resolved: bool,
108}
109
110/// A group of callers from a single file.
111#[derive(Debug, Clone, Serialize)]
112pub struct CallerGroup {
113    /// File path (relative to project root).
114    pub file: String,
115    /// Individual call sites in this file.
116    pub callers: Vec<CallerEntry>,
117}
118
119/// A single caller entry within a CallerGroup.
120#[derive(Debug, Clone, Serialize)]
121pub struct CallerEntry {
122    pub symbol: String,
123    /// 1-based line number of the call.
124    pub line: u32,
125}
126
127/// Result of a `callers_of` query.
128#[derive(Debug, Clone, Serialize)]
129pub struct CallersResult {
130    /// Target symbol queried.
131    pub symbol: String,
132    /// Target file queried.
133    pub file: String,
134    /// Caller groups, one per calling file.
135    pub callers: Vec<CallerGroup>,
136    /// Total number of call sites found.
137    pub total_callers: usize,
138    /// Number of files scanned to build the reverse index.
139    pub scanned_files: usize,
140}
141
142/// A node in the forward call tree.
143#[derive(Debug, Clone, Serialize)]
144pub struct CallTreeNode {
145    /// Symbol name.
146    pub name: String,
147    /// File path (relative to project root when possible).
148    pub file: String,
149    /// 1-based line number.
150    pub line: u32,
151    /// Function signature if available.
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub signature: Option<String>,
154    /// Whether this edge was resolved cross-file.
155    pub resolved: bool,
156    /// Child calls (recursive).
157    pub children: Vec<CallTreeNode>,
158}
159
160// ---------------------------------------------------------------------------
161// Entry point detection
162// ---------------------------------------------------------------------------
163
164/// Well-known main/init function names (case-insensitive exact match).
165const MAIN_INIT_NAMES: &[&str] = &["main", "init", "setup", "bootstrap", "run"];
166
167/// Determine whether a symbol is an entry point.
168///
169/// Entry points are:
170/// - Exported standalone functions (not methods — methods are class members)
171/// - Functions matching well-known main/init patterns (any language)
172/// - Test functions matching language-specific patterns
173pub fn is_entry_point(name: &str, kind: &SymbolKind, exported: bool, lang: LangId) -> bool {
174    // Exported standalone functions
175    if exported && *kind == SymbolKind::Function {
176        return true;
177    }
178
179    // Main/init patterns (case-insensitive exact match, any kind)
180    let lower = name.to_lowercase();
181    if MAIN_INIT_NAMES.contains(&lower.as_str()) {
182        return true;
183    }
184
185    // Test patterns by language
186    match lang {
187        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
188            // describe, it, test (exact), or starts with test/spec
189            matches!(lower.as_str(), "describe" | "it" | "test")
190                || lower.starts_with("test")
191                || lower.starts_with("spec")
192        }
193        LangId::Python => {
194            // starts with test_ or matches setUp/tearDown
195            lower.starts_with("test_") || matches!(name, "setUp" | "tearDown")
196        }
197        LangId::Rust => {
198            // starts with test_
199            lower.starts_with("test_")
200        }
201        LangId::Go => {
202            // starts with Test (case-sensitive)
203            name.starts_with("Test")
204        }
205        LangId::C
206        | LangId::Cpp
207        | LangId::Zig
208        | LangId::CSharp
209        | LangId::Bash
210        | LangId::Solidity
211        | LangId::Vue
212        | LangId::Json
213        | LangId::Scala
214        | LangId::Html
215        | LangId::Markdown => false,
216    }
217}
218
219// ---------------------------------------------------------------------------
220// Trace-to types
221// ---------------------------------------------------------------------------
222
223/// A single hop in a trace path.
224#[derive(Debug, Clone, Serialize)]
225pub struct TraceHop {
226    /// Symbol name at this hop.
227    pub symbol: String,
228    /// File path (relative to project root).
229    pub file: String,
230    /// 1-based line number.
231    pub line: u32,
232    /// Function signature if available.
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub signature: Option<String>,
235    /// Whether this hop is an entry point.
236    pub is_entry_point: bool,
237}
238
239/// A complete path from an entry point to the target symbol (top-down).
240#[derive(Debug, Clone, Serialize)]
241pub struct TracePath {
242    /// Hops from entry point (first) to target (last).
243    pub hops: Vec<TraceHop>,
244}
245
246/// Result of a `trace_to` query.
247#[derive(Debug, Clone, Serialize)]
248pub struct TraceToResult {
249    /// The target symbol that was traced.
250    pub target_symbol: String,
251    /// The target file (relative to project root).
252    pub target_file: String,
253    /// Complete paths from entry points to the target.
254    pub paths: Vec<TracePath>,
255    /// Total number of complete paths found.
256    pub total_paths: usize,
257    /// Number of distinct entry points found across all paths.
258    pub entry_points_found: usize,
259    /// Whether any path was cut short by the depth limit.
260    pub max_depth_reached: bool,
261    /// Number of paths that reached a dead end (no callers, not entry point).
262    pub truncated_paths: usize,
263}
264
265// ---------------------------------------------------------------------------
266// Impact analysis types
267// ---------------------------------------------------------------------------
268
269/// A single caller in an impact analysis result.
270#[derive(Debug, Clone, Serialize)]
271pub struct ImpactCaller {
272    /// Symbol that calls the target.
273    pub caller_symbol: String,
274    /// File containing the caller (relative to project root).
275    pub caller_file: String,
276    /// 1-based line number of the call site.
277    pub line: u32,
278    /// Caller's function/method signature, if available.
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub signature: Option<String>,
281    /// Whether the caller is an entry point.
282    pub is_entry_point: bool,
283    /// Source line at the call site (trimmed).
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub call_expression: Option<String>,
286    /// Parameter names extracted from the caller's signature.
287    pub parameters: Vec<String>,
288}
289
290/// Result of an `impact` query — enriched callers analysis.
291#[derive(Debug, Clone, Serialize)]
292pub struct ImpactResult {
293    /// The target symbol being analyzed.
294    pub symbol: String,
295    /// The target file (relative to project root).
296    pub file: String,
297    /// Target symbol's signature, if available.
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub signature: Option<String>,
300    /// Parameter names extracted from the target's signature.
301    pub parameters: Vec<String>,
302    /// Total number of affected call sites.
303    pub total_affected: usize,
304    /// Number of distinct files containing callers.
305    pub affected_files: usize,
306    /// Enriched caller details.
307    pub callers: Vec<ImpactCaller>,
308}
309
310// ---------------------------------------------------------------------------
311// Data flow tracking types
312// ---------------------------------------------------------------------------
313
314/// A single hop in a data flow trace.
315#[derive(Debug, Clone, Serialize)]
316pub struct DataFlowHop {
317    /// File path (relative to project root).
318    pub file: String,
319    /// Symbol (function/method) containing this hop.
320    pub symbol: String,
321    /// Variable or parameter name being tracked at this hop.
322    pub variable: String,
323    /// 1-based line number.
324    pub line: u32,
325    /// Type of data flow: "assignment", "parameter", or "return".
326    pub flow_type: String,
327    /// Whether this hop is an approximation (destructuring, spread, unresolved).
328    pub approximate: bool,
329}
330
331/// Result of a `trace_data` query — tracks how an expression flows through
332/// variable assignments and function parameters.
333#[derive(Debug, Clone, Serialize)]
334pub struct TraceDataResult {
335    /// The expression being tracked.
336    pub expression: String,
337    /// The file where tracking started.
338    pub origin_file: String,
339    /// The symbol where tracking started.
340    pub origin_symbol: String,
341    /// Hops through assignments and parameters.
342    pub hops: Vec<DataFlowHop>,
343    /// Whether tracking stopped due to depth limit.
344    pub depth_limited: bool,
345}
346
347/// Extract parameter names from a function signature string.
348///
349/// Strips language-specific receivers (`self`, `&self`, `&mut self` for Rust,
350/// `self` for Python) and type annotations / default values. Returns just
351/// the parameter names.
352pub fn extract_parameters(signature: &str, lang: LangId) -> Vec<String> {
353    // Find the parameter list between parentheses
354    let start = match signature.find('(') {
355        Some(i) => i + 1,
356        None => return Vec::new(),
357    };
358    let end = match signature[start..].find(')') {
359        Some(i) => start + i,
360        None => return Vec::new(),
361    };
362
363    let params_str = &signature[start..end].trim();
364    if params_str.is_empty() {
365        return Vec::new();
366    }
367
368    // Split on commas, respecting nested generics/brackets
369    let parts = split_params(params_str);
370
371    let mut result = Vec::new();
372    for part in parts {
373        let trimmed = part.trim();
374        if trimmed.is_empty() {
375            continue;
376        }
377
378        // Skip language-specific receivers
379        match lang {
380            LangId::Rust => {
381                if trimmed == "self"
382                    || trimmed == "mut self"
383                    || trimmed.starts_with("&self")
384                    || trimmed.starts_with("&mut self")
385                {
386                    continue;
387                }
388            }
389            LangId::Python => {
390                if trimmed == "self" || trimmed.starts_with("self:") {
391                    continue;
392                }
393            }
394            _ => {}
395        }
396
397        // Extract just the parameter name
398        let name = extract_param_name(trimmed, lang);
399        if !name.is_empty() {
400            result.push(name);
401        }
402    }
403
404    result
405}
406
407/// Split parameter string on commas, respecting nested brackets/generics.
408fn split_params(s: &str) -> Vec<String> {
409    let mut parts = Vec::new();
410    let mut current = String::new();
411    let mut depth = 0i32;
412
413    for ch in s.chars() {
414        match ch {
415            '<' | '[' | '{' | '(' => {
416                depth += 1;
417                current.push(ch);
418            }
419            '>' | ']' | '}' | ')' => {
420                depth -= 1;
421                current.push(ch);
422            }
423            ',' if depth == 0 => {
424                parts.push(current.clone());
425                current.clear();
426            }
427            _ => {
428                current.push(ch);
429            }
430        }
431    }
432    if !current.is_empty() {
433        parts.push(current);
434    }
435    parts
436}
437
438/// Extract the parameter name from a single parameter declaration.
439///
440/// Handles:
441/// - TS/JS: `name: Type`, `name = default`, `...name`, `name?: Type`
442/// - Python: `name: Type`, `name=default`, `*args`, `**kwargs`
443/// - Rust: `name: Type`, `mut name: Type`
444/// - Go: `name Type`, `name, name2 Type`
445fn extract_param_name(param: &str, lang: LangId) -> String {
446    let trimmed = param.trim();
447
448    // Handle rest/spread params
449    let working = if trimmed.starts_with("...") {
450        &trimmed[3..]
451    } else if trimmed.starts_with("**") {
452        &trimmed[2..]
453    } else if trimmed.starts_with('*') && lang == LangId::Python {
454        &trimmed[1..]
455    } else {
456        trimmed
457    };
458
459    // Rust: `mut name: Type` → strip `mut `
460    let working = if lang == LangId::Rust && working.starts_with("mut ") {
461        &working[4..]
462    } else {
463        working
464    };
465
466    // Strip type annotation (`: Type`) and default values (`= default`)
467    // Take only the name part — everything before `:`, `=`, or `?`
468    let name = working
469        .split(|c: char| c == ':' || c == '=')
470        .next()
471        .unwrap_or("")
472        .trim();
473
474    // Strip trailing `?` (optional params in TS)
475    let name = name.trim_end_matches('?');
476
477    // For Go, the name might be just `name Type` — take the first word
478    if lang == LangId::Go && !name.contains(' ') {
479        return name.to_string();
480    }
481    if lang == LangId::Go {
482        return name.split_whitespace().next().unwrap_or("").to_string();
483    }
484
485    name.to_string()
486}
487
488// ---------------------------------------------------------------------------
489// CallGraph
490// ---------------------------------------------------------------------------
491
492/// Worktree-scoped call graph with lazy per-file construction.
493///
494/// Files are parsed and analyzed on first access, then cached. The graph
495/// can resolve cross-file call edges using the import engine.
496pub struct CallGraph {
497    /// Cached per-file call data.
498    data: HashMap<PathBuf, FileCallData>,
499    /// Project root for relative path resolution.
500    project_root: PathBuf,
501    /// All files discovered in the worktree (lazily populated).
502    project_files: Option<Vec<PathBuf>>,
503    /// Reverse index: target_file → target_symbol → callers.
504    /// Built lazily on first `callers_of` call, cleared on `invalidate_file`.
505    reverse_index: Option<ReverseIndex>,
506}
507
508impl CallGraph {
509    /// Create a new call graph for a project.
510    pub fn new(project_root: PathBuf) -> Self {
511        Self {
512            data: HashMap::new(),
513            project_root,
514            project_files: None,
515            reverse_index: None,
516        }
517    }
518
519    /// Get the project root directory.
520    pub fn project_root(&self) -> &Path {
521        &self.project_root
522    }
523
524    fn resolve_cross_file_edge_with_exports<F>(
525        full_callee: &str,
526        short_name: &str,
527        caller_file: &Path,
528        import_block: &ImportBlock,
529        mut file_exports_symbol: F,
530    ) -> EdgeResolution
531    where
532        F: FnMut(&Path, &str) -> bool,
533    {
534        let caller_dir = caller_file.parent().unwrap_or(Path::new("."));
535
536        // Check namespace imports: "utils.foo" where utils is a namespace import
537        if full_callee.contains('.') {
538            let parts: Vec<&str> = full_callee.splitn(2, '.').collect();
539            if parts.len() == 2 {
540                let namespace = parts[0];
541                let member = parts[1];
542
543                for imp in &import_block.imports {
544                    if imp.namespace_import.as_deref() == Some(namespace) {
545                        if let Some(resolved_path) =
546                            resolve_module_path(caller_dir, &imp.module_path)
547                        {
548                            return EdgeResolution::Resolved {
549                                file: resolved_path,
550                                symbol: member.to_owned(),
551                            };
552                        }
553                    }
554                }
555            }
556        }
557
558        // Check named imports (direct and aliased)
559        for imp in &import_block.imports {
560            // Direct named import: import { foo } from './utils'
561            if imp.names.iter().any(|name| name == short_name) {
562                if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
563                    // The name in the import is the original name from the source module
564                    return EdgeResolution::Resolved {
565                        file: resolved_path,
566                        symbol: short_name.to_owned(),
567                    };
568                }
569            }
570
571            // Default import: import foo from './utils'
572            if imp.default_import.as_deref() == Some(short_name) {
573                if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
574                    return EdgeResolution::Resolved {
575                        file: resolved_path,
576                        symbol: "default".to_owned(),
577                    };
578                }
579            }
580        }
581
582        // Check aliased imports by examining the raw import text.
583        // ImportStatement.names stores the original name (foo), but the local code
584        // uses the alias (bar). We need to parse `import { foo as bar }` to find
585        // that `bar` maps to `foo`.
586        if let Some((original_name, resolved_path)) =
587            resolve_aliased_import(short_name, import_block, caller_dir)
588        {
589            return EdgeResolution::Resolved {
590                file: resolved_path,
591                symbol: original_name,
592            };
593        }
594
595        // Try barrel file re-exports: if any import points to an index file,
596        // check if that file re-exports the symbol
597        for imp in &import_block.imports {
598            if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
599                // Check if the resolved path is a directory (barrel file)
600                if resolved_path.is_dir() {
601                    if let Some(index_path) = find_index_file(&resolved_path) {
602                        // Check if the index file exports this symbol
603                        if file_exports_symbol(&index_path, short_name) {
604                            return EdgeResolution::Resolved {
605                                file: index_path,
606                                symbol: short_name.to_owned(),
607                            };
608                        }
609                    }
610                } else if file_exports_symbol(&resolved_path, short_name) {
611                    return EdgeResolution::Resolved {
612                        file: resolved_path,
613                        symbol: short_name.to_owned(),
614                    };
615                }
616            }
617        }
618
619        EdgeResolution::Unresolved {
620            callee_name: short_name.to_owned(),
621        }
622    }
623
624    /// Get or build the call data for a file.
625    pub fn build_file(&mut self, path: &Path) -> Result<&FileCallData, AftError> {
626        let canon = self.canonicalize(path)?;
627
628        if !self.data.contains_key(&canon) {
629            let file_data = build_file_data(&canon)?;
630            self.data.insert(canon.clone(), file_data);
631        }
632
633        Ok(&self.data[&canon])
634    }
635
636    /// Resolve a cross-file call edge.
637    ///
638    /// Given a callee expression and the calling file's import block,
639    /// determines which file and symbol the call targets.
640    pub fn resolve_cross_file_edge(
641        &mut self,
642        full_callee: &str,
643        short_name: &str,
644        caller_file: &Path,
645        import_block: &ImportBlock,
646    ) -> EdgeResolution {
647        Self::resolve_cross_file_edge_with_exports(
648            full_callee,
649            short_name,
650            caller_file,
651            import_block,
652            |path, symbol_name| self.file_exports_symbol(path, symbol_name),
653        )
654    }
655
656    /// Check if a file exports a given symbol name.
657    fn file_exports_symbol(&mut self, path: &Path, symbol_name: &str) -> bool {
658        match self.build_file(path) {
659            Ok(data) => data.exported_symbols.iter().any(|name| name == symbol_name),
660            Err(_) => false,
661        }
662    }
663
664    fn file_exports_symbol_cached(&self, path: &Path, symbol_name: &str) -> bool {
665        self.lookup_file_data(path)
666            .map(|data| data.exported_symbols.iter().any(|name| name == symbol_name))
667            .unwrap_or(false)
668    }
669
670    /// Depth-limited forward call tree traversal.
671    ///
672    /// Starting from a (file, symbol) pair, recursively follows calls
673    /// up to `max_depth` levels. Uses a visited set for cycle detection.
674    pub fn forward_tree(
675        &mut self,
676        file: &Path,
677        symbol: &str,
678        max_depth: usize,
679    ) -> Result<CallTreeNode, AftError> {
680        let mut visited = HashSet::new();
681        self.forward_tree_inner(file, symbol, max_depth, 0, &mut visited)
682    }
683
684    fn forward_tree_inner(
685        &mut self,
686        file: &Path,
687        symbol: &str,
688        max_depth: usize,
689        current_depth: usize,
690        visited: &mut HashSet<(PathBuf, String)>,
691    ) -> Result<CallTreeNode, AftError> {
692        let canon = self.canonicalize(file)?;
693        let visit_key = (canon.clone(), symbol.to_string());
694
695        // Cycle detection
696        if visited.contains(&visit_key) {
697            let (line, signature) = self
698                .lookup_file_data(&canon)
699                .map(|data| get_symbol_meta_from_data(data, symbol))
700                .unwrap_or_else(|| get_symbol_meta(&canon, symbol));
701            return Ok(CallTreeNode {
702                name: symbol.to_string(),
703                file: self.relative_path(&canon),
704                line,
705                signature,
706                resolved: true,
707                children: vec![], // cycle — stop recursion
708            });
709        }
710
711        visited.insert(visit_key.clone());
712
713        let (import_block, call_sites, sym_line, sym_signature) = {
714            let file_data = self.build_file(&canon)?;
715            let meta = get_symbol_meta_from_data(file_data, symbol);
716
717            (
718                file_data.import_block.clone(),
719                file_data
720                    .calls_by_symbol
721                    .get(symbol)
722                    .cloned()
723                    .unwrap_or_default(),
724                meta.0,
725                meta.1,
726            )
727        };
728
729        // Build children
730        let mut children = Vec::new();
731
732        if current_depth < max_depth {
733            for call_site in &call_sites {
734                let edge = self.resolve_cross_file_edge(
735                    &call_site.full_callee,
736                    &call_site.callee_name,
737                    &canon,
738                    &import_block,
739                );
740
741                match edge {
742                    EdgeResolution::Resolved {
743                        file: ref target_file,
744                        ref symbol,
745                    } => {
746                        match self.forward_tree_inner(
747                            target_file,
748                            symbol,
749                            max_depth,
750                            current_depth + 1,
751                            visited,
752                        ) {
753                            Ok(child) => children.push(child),
754                            Err(_) => {
755                                // Target file can't be parsed — mark as unresolved leaf
756                                children.push(CallTreeNode {
757                                    name: call_site.callee_name.clone(),
758                                    file: self.relative_path(target_file),
759                                    line: call_site.line,
760                                    signature: None,
761                                    resolved: false,
762                                    children: vec![],
763                                });
764                            }
765                        }
766                    }
767                    EdgeResolution::Unresolved { callee_name } => {
768                        children.push(CallTreeNode {
769                            name: callee_name,
770                            file: self.relative_path(&canon),
771                            line: call_site.line,
772                            signature: None,
773                            resolved: false,
774                            children: vec![],
775                        });
776                    }
777                }
778            }
779        }
780
781        visited.remove(&visit_key);
782
783        Ok(CallTreeNode {
784            name: symbol.to_string(),
785            file: self.relative_path(&canon),
786            line: sym_line,
787            signature: sym_signature,
788            resolved: true,
789            children,
790        })
791    }
792
793    /// Get all project files (lazily discovered).
794    pub fn project_files(&mut self) -> &[PathBuf] {
795        if self.project_files.is_none() {
796            let project_root = self.project_root.clone();
797            self.project_files = Some(walk_project_files(&project_root).collect());
798        }
799        self.project_files.as_deref().unwrap_or(&[])
800    }
801
802    /// Get the total number of project source files.
803    ///
804    /// Triggers project file discovery on first access and returns the cached
805    /// count thereafter. Prefer [`project_file_count_bounded`] when the caller
806    /// only needs to know whether a threshold is exceeded.
807    pub fn project_file_count(&mut self) -> usize {
808        self.project_files().len()
809    }
810
811    /// Count project source files, stopping after `limit + 1` so huge roots
812    /// do not pay for a full walk or allocate a giant vector.
813    ///
814    /// Returns the real count when ≤ `limit`, or `limit + 1` when exceeded.
815    /// Uses the cached `project_files` vec when it already exists (e.g. a
816    /// previous call-graph op succeeded at this cap), otherwise short-circuits
817    /// the underlying `ignore::Walk` iterator via `.take(limit + 1)`.
818    ///
819    /// CRITICAL: This method must NOT populate `self.project_files`. The whole
820    /// point is to reject oversized roots before the full walk-and-collect runs.
821    pub fn project_file_count_bounded(&self, limit: usize) -> usize {
822        if let Some(files) = self.project_files.as_deref() {
823            return files.len();
824        }
825        walk_project_files(&self.project_root)
826            .take(limit.saturating_add(1))
827            .count()
828    }
829
830    /// Build the reverse index by scanning all project files.
831    ///
832    /// For each file, builds the call data (if not cached), then for each
833    /// (symbol, call_sites) pair, resolves cross-file edges and inserts
834    /// into the reverse map: `(target_file, target_symbol) → Vec<CallerSite>`.
835    fn build_reverse_index(&mut self, max_files: usize) -> Result<(), AftError> {
836        // Bounded count first — never populate project_files on oversized roots.
837        // `walk_project_files(...).take(max_files + 1)` is lazy (Walk is an
838        // iterator), so this costs at most (max_files + 1) directory entries
839        // worth of work, not a full O(N) walk of the whole tree.
840        let count = self.project_file_count_bounded(max_files);
841        if count > max_files {
842            return Err(AftError::ProjectTooLarge {
843                count,
844                max: max_files,
845            });
846        }
847
848        // TODO(v0.16): rust-side deadline for graceful timeout recovery
849        // (unbounded walks remain a soft cliff for users who raise the cap).
850        // Discover all project files first
851        let all_files = self.project_files().to_vec();
852
853        // Build file data for all project files
854        let uncached_files: Vec<PathBuf> = all_files
855            .iter()
856            .filter(|f| self.lookup_file_data(f).is_none())
857            .cloned()
858            .collect();
859
860        let computed: Vec<(PathBuf, FileCallData)> = uncached_files
861            .par_iter()
862            .filter_map(|f| build_file_data(f).ok().map(|data| (f.clone(), data)))
863            .collect();
864
865        for (file, data) in computed {
866            self.data.insert(file, data);
867        }
868
869        // Now build the reverse map
870        let mut reverse: ReverseIndex = HashMap::new();
871
872        for caller_file in &all_files {
873            // Canonicalize the caller file path for consistent lookups
874            let canon_caller = Arc::new(
875                std::fs::canonicalize(caller_file).unwrap_or_else(|_| caller_file.clone()),
876            );
877            let file_data = match self
878                .data
879                .get(caller_file)
880                .or_else(|| self.data.get(canon_caller.as_ref()))
881            {
882                Some(d) => d,
883                None => continue,
884            };
885
886            for (symbol_name, call_sites) in &file_data.calls_by_symbol {
887                let caller_symbol: SharedStr = Arc::from(symbol_name.as_str());
888
889                for call_site in call_sites {
890                    let edge = Self::resolve_cross_file_edge_with_exports(
891                        &call_site.full_callee,
892                        &call_site.callee_name,
893                        canon_caller.as_ref(),
894                        &file_data.import_block,
895                        |path, symbol_name| self.file_exports_symbol_cached(path, symbol_name),
896                    );
897
898                    let (target_file, target_symbol, resolved) = match edge {
899                        EdgeResolution::Resolved { file, symbol } => (file, symbol, true),
900                        EdgeResolution::Unresolved { callee_name } => {
901                            (canon_caller.as_ref().clone(), callee_name, false)
902                        }
903                    };
904
905                    reverse
906                        .entry(target_file)
907                        .or_default()
908                        .entry(target_symbol)
909                        .or_default()
910                        .push(IndexedCallerSite {
911                            caller_file: Arc::clone(&canon_caller),
912                            caller_symbol: Arc::clone(&caller_symbol),
913                            line: call_site.line,
914                            col: 0,
915                            resolved,
916                        });
917                }
918            }
919        }
920
921        self.reverse_index = Some(reverse);
922        Ok(())
923    }
924
925    fn reverse_sites(&self, file: &Path, symbol: &str) -> Option<&[IndexedCallerSite]> {
926        self.reverse_index
927            .as_ref()?
928            .get(file)?
929            .get(symbol)
930            .map(Vec::as_slice)
931    }
932
933    /// Get callers of a symbol in a file, grouped by calling file.
934    ///
935    /// Builds the reverse index on first call (scans all project files).
936    /// Supports recursive depth expansion: depth=1 returns direct callers,
937    /// depth=2 returns callers-of-callers, etc. depth=0 is treated as 1.
938    pub fn callers_of(
939        &mut self,
940        file: &Path,
941        symbol: &str,
942        depth: usize,
943        max_files: usize,
944    ) -> Result<CallersResult, AftError> {
945        let canon = self.canonicalize(file)?;
946
947        // Ensure file is built (may already be cached)
948        self.build_file(&canon)?;
949
950        // Build the reverse index if not cached
951        if self.reverse_index.is_none() {
952            self.build_reverse_index(max_files)?;
953        }
954
955        let scanned_files = self.project_files.as_ref().map(|f| f.len()).unwrap_or(0);
956        let effective_depth = if depth == 0 { 1 } else { depth };
957
958        let mut visited = HashSet::new();
959        let mut all_sites: Vec<CallerSite> = Vec::new();
960        self.collect_callers_recursive(
961            &canon,
962            symbol,
963            effective_depth,
964            0,
965            &mut visited,
966            &mut all_sites,
967        );
968
969        // Group by file
970
971        let mut groups_map: HashMap<PathBuf, Vec<CallerEntry>> = HashMap::new();
972        let total_callers = all_sites.len();
973        for site in all_sites {
974            let caller_file: PathBuf = site.caller_file;
975            let caller_symbol: String = site.caller_symbol;
976            let line = site.line;
977            let entry = CallerEntry {
978                symbol: caller_symbol,
979                line,
980            };
981
982            if let Some(entries) = groups_map.get_mut(&caller_file) {
983                entries.push(entry);
984            } else {
985                groups_map.insert(caller_file, vec![entry]);
986            }
987        }
988
989        let mut callers: Vec<CallerGroup> = groups_map
990            .into_iter()
991            .map(|(file_path, entries)| CallerGroup {
992                file: self.relative_path(&file_path),
993                callers: entries,
994            })
995            .collect();
996
997        // Sort groups by file path for deterministic output
998        callers.sort_by(|a, b| a.file.cmp(&b.file));
999
1000        Ok(CallersResult {
1001            symbol: symbol.to_string(),
1002            file: self.relative_path(&canon),
1003            callers,
1004            total_callers,
1005            scanned_files,
1006        })
1007    }
1008
1009    /// Trace backward from a symbol to all entry points.
1010    ///
1011    /// Returns complete paths (top-down: entry point first, target last).
1012    /// Uses BFS backward through the reverse index, with per-path cycle
1013    /// detection and depth limiting.
1014    pub fn trace_to(
1015        &mut self,
1016        file: &Path,
1017        symbol: &str,
1018        max_depth: usize,
1019        max_files: usize,
1020    ) -> Result<TraceToResult, AftError> {
1021        let canon = self.canonicalize(file)?;
1022
1023        // Ensure file is built
1024        self.build_file(&canon)?;
1025
1026        // Build the reverse index if not cached
1027        if self.reverse_index.is_none() {
1028            self.build_reverse_index(max_files)?;
1029        }
1030
1031        let target_rel = self.relative_path(&canon);
1032        let effective_max = if max_depth == 0 { 10 } else { max_depth };
1033        if self.reverse_index.is_none() {
1034            return Err(AftError::ParseError {
1035                message: format!(
1036                    "reverse index unavailable after building callers for {}",
1037                    canon.display()
1038                ),
1039            });
1040        }
1041
1042        // Get line/signature for the target symbol
1043        let (target_line, target_sig) = self
1044            .lookup_file_data(&canon)
1045            .map(|data| get_symbol_meta_from_data(data, symbol))
1046            .unwrap_or_else(|| get_symbol_meta(&canon, symbol));
1047
1048        // Check if target itself is an entry point
1049        let target_is_entry = self
1050            .lookup_file_data(&canon)
1051            .and_then(|fd| {
1052                let meta = fd.symbol_metadata.get(symbol)?;
1053                Some(is_entry_point(symbol, &meta.kind, meta.exported, fd.lang))
1054            })
1055            .unwrap_or(false);
1056
1057        // BFS state: each item is a partial path (bottom-up, will be reversed later)
1058        // Each path element: (canonicalized file, symbol name, line, signature)
1059        type PathElem = (SharedPath, SharedStr, u32, Option<String>);
1060        let mut complete_paths: Vec<Vec<PathElem>> = Vec::new();
1061        let mut max_depth_reached = false;
1062        let mut truncated_paths: usize = 0;
1063
1064        // Initial path starts at the target
1065        let initial: Vec<PathElem> = vec![(
1066            Arc::new(canon.clone()),
1067            Arc::from(symbol),
1068            target_line,
1069            target_sig,
1070        )];
1071
1072        // If the target itself is an entry point, record it as a trivial path
1073        if target_is_entry {
1074            complete_paths.push(initial.clone());
1075        }
1076
1077        // Queue of (current_path, depth)
1078        let mut queue: Vec<(Vec<PathElem>, usize)> = vec![(initial, 0)];
1079
1080        while let Some((path, depth)) = queue.pop() {
1081            if depth >= effective_max {
1082                max_depth_reached = true;
1083                continue;
1084            }
1085
1086            let Some((current_file, current_symbol, _, _)) = path.last() else {
1087                continue;
1088            };
1089
1090            // Look up callers in reverse index
1091            let callers = match self.reverse_sites(current_file.as_ref(), current_symbol.as_ref()) {
1092                Some(sites) => sites,
1093                None => {
1094                    // Dead end: no callers and not an entry point
1095                    // (if it were an entry point, we'd have recorded it already)
1096                    if path.len() > 1 {
1097                        // Only count as truncated if this isn't the target itself
1098                        // (the target with no callers is just "no paths found")
1099                        truncated_paths += 1;
1100                    }
1101                    continue;
1102                }
1103            };
1104
1105            let mut has_new_path = false;
1106            for site in callers {
1107                // Cycle detection: skip if this caller is already in the current path
1108                if path.iter().any(|(file_path, sym, _, _)| {
1109                    file_path.as_ref() == site.caller_file.as_ref()
1110                        && sym.as_ref() == site.caller_symbol.as_ref()
1111                }) {
1112                    continue;
1113                }
1114
1115                has_new_path = true;
1116
1117                // Get caller's metadata
1118                let (caller_line, caller_sig) = self
1119                    .lookup_file_data(site.caller_file.as_ref())
1120                    .map(|data| get_symbol_meta_from_data(data, site.caller_symbol.as_ref()))
1121                    .unwrap_or_else(|| {
1122                        get_symbol_meta(site.caller_file.as_ref(), site.caller_symbol.as_ref())
1123                    });
1124
1125                let mut new_path = path.clone();
1126                new_path.push((
1127                    Arc::clone(&site.caller_file),
1128                    Arc::clone(&site.caller_symbol),
1129                    caller_line,
1130                    caller_sig,
1131                ));
1132
1133                // Check if this caller is an entry point
1134                // Try both canonical and non-canonical keys (build_reverse_index
1135                // may have stored data under the raw walker path)
1136                let caller_is_entry = self
1137                    .lookup_file_data(site.caller_file.as_ref())
1138                    .and_then(|fd| {
1139                        let meta = fd.symbol_metadata.get(site.caller_symbol.as_ref())?;
1140                        Some(is_entry_point(
1141                            site.caller_symbol.as_ref(),
1142                            &meta.kind,
1143                            meta.exported,
1144                            fd.lang,
1145                        ))
1146                    })
1147                    .unwrap_or(false);
1148
1149                if caller_is_entry {
1150                    complete_paths.push(new_path.clone());
1151                }
1152                // Always continue searching backward — there may be longer
1153                // paths through other entry points beyond this one
1154                queue.push((new_path, depth + 1));
1155            }
1156
1157            // If we had callers but none were new (all cycles), count as truncated
1158            if !has_new_path && path.len() > 1 {
1159                truncated_paths += 1;
1160            }
1161        }
1162
1163        // Reverse each path so it reads top-down (entry point → ... → target)
1164        // and convert to TraceHop/TracePath
1165        let mut paths: Vec<TracePath> = complete_paths
1166            .into_iter()
1167            .map(|mut elems| {
1168                elems.reverse();
1169                let hops: Vec<TraceHop> = elems
1170                    .iter()
1171                    .enumerate()
1172                    .map(|(i, (file_path, sym, line, sig))| {
1173                        let is_ep = if i == 0 {
1174                            // First hop (after reverse) is the entry point
1175                            self.lookup_file_data(file_path.as_ref())
1176                                .and_then(|fd| {
1177                                    let meta = fd.symbol_metadata.get(sym.as_ref())?;
1178                                    Some(is_entry_point(
1179                                        sym.as_ref(),
1180                                        &meta.kind,
1181                                        meta.exported,
1182                                        fd.lang,
1183                                    ))
1184                                })
1185                                .unwrap_or(false)
1186                        } else {
1187                            false
1188                        };
1189                        TraceHop {
1190                            symbol: sym.to_string(),
1191                            file: self.relative_path(file_path.as_ref()),
1192                            line: *line,
1193                            signature: sig.clone(),
1194                            is_entry_point: is_ep,
1195                        }
1196                    })
1197                    .collect();
1198                TracePath { hops }
1199            })
1200            .collect();
1201
1202        // Sort paths for deterministic output (by entry point name, then path length)
1203        paths.sort_by(|a, b| {
1204            let a_entry = a.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1205            let b_entry = b.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1206            a_entry.cmp(b_entry).then(a.hops.len().cmp(&b.hops.len()))
1207        });
1208
1209        // Count distinct entry points
1210        let mut entry_point_names: HashSet<String> = HashSet::new();
1211        for p in &paths {
1212            if let Some(first) = p.hops.first() {
1213                if first.is_entry_point {
1214                    entry_point_names.insert(first.symbol.clone());
1215                }
1216            }
1217        }
1218
1219        let total_paths = paths.len();
1220        let entry_points_found = entry_point_names.len();
1221
1222        Ok(TraceToResult {
1223            target_symbol: symbol.to_string(),
1224            target_file: target_rel,
1225            paths,
1226            total_paths,
1227            entry_points_found,
1228            max_depth_reached,
1229            truncated_paths,
1230        })
1231    }
1232
1233    /// Impact analysis: enriched callers query.
1234    ///
1235    /// Returns all call sites affected by a change to the given symbol,
1236    /// annotated with each caller's signature, entry point status, the
1237    /// source line at the call site, and extracted parameter names.
1238    pub fn impact(
1239        &mut self,
1240        file: &Path,
1241        symbol: &str,
1242        depth: usize,
1243        max_files: usize,
1244    ) -> Result<ImpactResult, AftError> {
1245        let canon = self.canonicalize(file)?;
1246
1247        // Ensure file is built
1248        self.build_file(&canon)?;
1249
1250        // Build the reverse index if not cached
1251        if self.reverse_index.is_none() {
1252            self.build_reverse_index(max_files)?;
1253        }
1254
1255        let effective_depth = if depth == 0 { 1 } else { depth };
1256
1257        // Get the target symbol's own metadata
1258        let (target_signature, target_parameters, target_lang) = {
1259            let file_data = match self.data.get(&canon) {
1260                Some(d) => d,
1261                None => {
1262                    return Err(AftError::InvalidRequest {
1263                        message: "file data missing after build".to_string(),
1264                    })
1265                }
1266            };
1267            let meta = file_data.symbol_metadata.get(symbol);
1268            let sig = meta.and_then(|m| m.signature.clone());
1269            let lang = file_data.lang;
1270            let params = sig
1271                .as_deref()
1272                .map(|s| extract_parameters(s, lang))
1273                .unwrap_or_default();
1274            (sig, params, lang)
1275        };
1276
1277        // Collect all caller sites (transitive)
1278        let mut visited = HashSet::new();
1279        let mut all_sites: Vec<CallerSite> = Vec::new();
1280        self.collect_callers_recursive(
1281            &canon,
1282            symbol,
1283            effective_depth,
1284            0,
1285            &mut visited,
1286            &mut all_sites,
1287        );
1288
1289        // Deduplicate sites by (file, symbol, line)
1290        let mut seen: HashSet<(PathBuf, String, u32)> = HashSet::new();
1291        all_sites.retain(|site| {
1292            seen.insert((
1293                site.caller_file.clone(),
1294                site.caller_symbol.clone(),
1295                site.line,
1296            ))
1297        });
1298
1299        // Enrich each caller site
1300        let mut callers = Vec::new();
1301        let mut affected_file_set = HashSet::new();
1302
1303        for site in &all_sites {
1304            // Build the caller's file to get metadata
1305            if let Err(e) = self.build_file(site.caller_file.as_path()) {
1306                log::debug!(
1307                    "callgraph: skipping caller file {}: {}",
1308                    site.caller_file.display(),
1309                    e
1310                );
1311            }
1312
1313            let (sig, is_ep, params, _lang) = {
1314                if let Some(fd) = self.lookup_file_data(site.caller_file.as_path()) {
1315                    let meta = fd.symbol_metadata.get(&site.caller_symbol);
1316                    let sig = meta.and_then(|m| m.signature.clone());
1317                    let kind = meta.map(|m| m.kind.clone()).unwrap_or(SymbolKind::Function);
1318                    let exported = meta.map(|m| m.exported).unwrap_or(false);
1319                    let is_ep = is_entry_point(&site.caller_symbol, &kind, exported, fd.lang);
1320                    let lang = fd.lang;
1321                    let params = sig
1322                        .as_deref()
1323                        .map(|s| extract_parameters(s, lang))
1324                        .unwrap_or_default();
1325                    (sig, is_ep, params, lang)
1326                } else {
1327                    (None, false, Vec::new(), target_lang)
1328                }
1329            };
1330
1331            // Read the source line at the call site
1332            let call_expression = self.read_source_line(site.caller_file.as_path(), site.line);
1333
1334            let rel_file = self.relative_path(site.caller_file.as_path());
1335            affected_file_set.insert(rel_file.clone());
1336
1337            callers.push(ImpactCaller {
1338                caller_symbol: site.caller_symbol.clone(),
1339                caller_file: rel_file,
1340                line: site.line,
1341                signature: sig,
1342                is_entry_point: is_ep,
1343                call_expression,
1344                parameters: params,
1345            });
1346        }
1347
1348        // Sort callers by file then line for deterministic output
1349        callers.sort_by(|a, b| a.caller_file.cmp(&b.caller_file).then(a.line.cmp(&b.line)));
1350
1351        let total_affected = callers.len();
1352        let affected_files = affected_file_set.len();
1353
1354        Ok(ImpactResult {
1355            symbol: symbol.to_string(),
1356            file: self.relative_path(&canon),
1357            signature: target_signature,
1358            parameters: target_parameters,
1359            total_affected,
1360            affected_files,
1361            callers,
1362        })
1363    }
1364
1365    /// Trace how an expression flows through variable assignments within a
1366    /// function body and across function boundaries via argument-to-parameter
1367    /// matching.
1368    ///
1369    /// Algorithm:
1370    /// 1. Parse the function body, find the expression text.
1371    /// 2. Walk AST for assignments that reference the tracked name.
1372    /// 3. When the tracked name appears as a call argument, resolve the callee,
1373    ///    match argument position to parameter name, recurse.
1374    /// 4. Destructuring, spread, and unresolved calls produce approximate hops.
1375    pub fn trace_data(
1376        &mut self,
1377        file: &Path,
1378        symbol: &str,
1379        expression: &str,
1380        max_depth: usize,
1381        max_files: usize,
1382    ) -> Result<TraceDataResult, AftError> {
1383        let canon = self.canonicalize(file)?;
1384        let rel_file = self.relative_path(&canon);
1385
1386        // Ensure file data is built
1387        self.build_file(&canon)?;
1388
1389        // Verify symbol exists
1390        {
1391            let fd = match self.data.get(&canon) {
1392                Some(d) => d,
1393                None => {
1394                    return Err(AftError::InvalidRequest {
1395                        message: "file data missing after build".to_string(),
1396                    })
1397                }
1398            };
1399            let has_symbol = fd.calls_by_symbol.contains_key(symbol)
1400                || fd.exported_symbols.iter().any(|name| name == symbol)
1401                || fd.symbol_metadata.contains_key(symbol);
1402            if !has_symbol {
1403                return Err(AftError::InvalidRequest {
1404                    message: format!(
1405                        "trace_data: symbol '{}' not found in {}",
1406                        symbol,
1407                        file.display()
1408                    ),
1409                });
1410            }
1411        }
1412
1413        // Bounded count: short-circuits at `max_files + 1` so oversized roots
1414        // reject in microseconds instead of paying the full walk/collect cost.
1415        // Matches the guard used by build_reverse_index / callers_of / trace_to / impact.
1416        let count = self.project_file_count_bounded(max_files);
1417        if count > max_files {
1418            return Err(AftError::ProjectTooLarge {
1419                count,
1420                max: max_files,
1421            });
1422        }
1423
1424        let mut hops = Vec::new();
1425        let mut depth_limited = false;
1426
1427        self.trace_data_inner(
1428            &canon,
1429            symbol,
1430            expression,
1431            max_depth,
1432            0,
1433            &mut hops,
1434            &mut depth_limited,
1435            &mut HashSet::new(),
1436        );
1437
1438        Ok(TraceDataResult {
1439            expression: expression.to_string(),
1440            origin_file: rel_file,
1441            origin_symbol: symbol.to_string(),
1442            hops,
1443            depth_limited,
1444        })
1445    }
1446
1447    /// Inner recursive data flow tracking.
1448    fn trace_data_inner(
1449        &mut self,
1450        file: &Path,
1451        symbol: &str,
1452        tracking_name: &str,
1453        max_depth: usize,
1454        current_depth: usize,
1455        hops: &mut Vec<DataFlowHop>,
1456        depth_limited: &mut bool,
1457        visited: &mut HashSet<(PathBuf, String, String)>,
1458    ) {
1459        let visit_key = (
1460            file.to_path_buf(),
1461            symbol.to_string(),
1462            tracking_name.to_string(),
1463        );
1464        if visited.contains(&visit_key) {
1465            return; // cycle
1466        }
1467        visited.insert(visit_key);
1468
1469        // Read and parse the file
1470        let source = match std::fs::read_to_string(file) {
1471            Ok(s) => s,
1472            Err(_) => return,
1473        };
1474
1475        let lang = match detect_language(file) {
1476            Some(l) => l,
1477            None => return,
1478        };
1479
1480        let grammar = grammar_for(lang);
1481        let mut parser = Parser::new();
1482        if parser.set_language(&grammar).is_err() {
1483            return;
1484        }
1485        let tree = match parser.parse(&source, None) {
1486            Some(t) => t,
1487            None => return,
1488        };
1489
1490        // Find the symbol's AST node range
1491        let symbols = match crate::parser::extract_symbols_from_tree(&source, &tree, lang) {
1492            Ok(symbols) => symbols,
1493            Err(_) => return,
1494        };
1495        let sym_info = match symbols.iter().find(|s| s.name == symbol) {
1496            Some(s) => s,
1497            None => return,
1498        };
1499
1500        let body_start =
1501            line_col_to_byte(&source, sym_info.range.start_line, sym_info.range.start_col);
1502        let body_end = line_col_to_byte(&source, sym_info.range.end_line, sym_info.range.end_col);
1503
1504        let root = tree.root_node();
1505
1506        // Find the symbol's body node (the function/method definition node)
1507        let body_node = match find_node_covering_range(root, body_start, body_end) {
1508            Some(n) => n,
1509            None => return,
1510        };
1511
1512        // Track names through the body
1513        let mut tracked_names: Vec<String> = vec![tracking_name.to_string()];
1514        let rel_file = self.relative_path(file);
1515
1516        // Walk the body looking for assignments and calls
1517        self.walk_for_data_flow(
1518            body_node,
1519            &source,
1520            &mut tracked_names,
1521            file,
1522            symbol,
1523            &rel_file,
1524            lang,
1525            max_depth,
1526            current_depth,
1527            hops,
1528            depth_limited,
1529            visited,
1530        );
1531    }
1532
1533    /// Walk an AST subtree looking for assignments and call expressions that
1534    /// reference tracked names.
1535    #[allow(clippy::too_many_arguments)]
1536    fn walk_for_data_flow(
1537        &mut self,
1538        node: tree_sitter::Node,
1539        source: &str,
1540        tracked_names: &mut Vec<String>,
1541        file: &Path,
1542        symbol: &str,
1543        rel_file: &str,
1544        lang: LangId,
1545        max_depth: usize,
1546        current_depth: usize,
1547        hops: &mut Vec<DataFlowHop>,
1548        depth_limited: &mut bool,
1549        visited: &mut HashSet<(PathBuf, String, String)>,
1550    ) {
1551        let kind = node.kind();
1552
1553        // Check for variable declarations / assignments
1554        let is_var_decl = matches!(
1555            kind,
1556            "variable_declarator"
1557                | "assignment_expression"
1558                | "augmented_assignment_expression"
1559                | "assignment"
1560                | "let_declaration"
1561                | "short_var_declaration"
1562        );
1563
1564        if is_var_decl {
1565            if let Some((new_name, init_text, line, is_approx)) =
1566                self.extract_assignment_info(node, source, lang, tracked_names)
1567            {
1568                // The RHS references a tracked name — add assignment hop
1569                if !is_approx {
1570                    hops.push(DataFlowHop {
1571                        file: rel_file.to_string(),
1572                        symbol: symbol.to_string(),
1573                        variable: new_name.clone(),
1574                        line,
1575                        flow_type: "assignment".to_string(),
1576                        approximate: false,
1577                    });
1578                    tracked_names.push(new_name);
1579                } else {
1580                    // Destructuring or pattern — approximate
1581                    hops.push(DataFlowHop {
1582                        file: rel_file.to_string(),
1583                        symbol: symbol.to_string(),
1584                        variable: init_text,
1585                        line,
1586                        flow_type: "assignment".to_string(),
1587                        approximate: true,
1588                    });
1589                    // Don't track further through this branch
1590                    return;
1591                }
1592            }
1593        }
1594
1595        // Check for call expressions where tracked name is an argument
1596        if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
1597            self.check_call_for_data_flow(
1598                node,
1599                source,
1600                tracked_names,
1601                file,
1602                symbol,
1603                rel_file,
1604                lang,
1605                max_depth,
1606                current_depth,
1607                hops,
1608                depth_limited,
1609                visited,
1610            );
1611        }
1612
1613        // Recurse into children
1614        let mut cursor = node.walk();
1615        if cursor.goto_first_child() {
1616            loop {
1617                let child = cursor.node();
1618                // Don't re-process the current node type in recursion
1619                self.walk_for_data_flow(
1620                    child,
1621                    source,
1622                    tracked_names,
1623                    file,
1624                    symbol,
1625                    rel_file,
1626                    lang,
1627                    max_depth,
1628                    current_depth,
1629                    hops,
1630                    depth_limited,
1631                    visited,
1632                );
1633                if !cursor.goto_next_sibling() {
1634                    break;
1635                }
1636            }
1637        }
1638    }
1639
1640    /// Check if an assignment/declaration node assigns from a tracked name.
1641    /// Returns (new_name, init_text, line, is_approximate).
1642    fn extract_assignment_info(
1643        &self,
1644        node: tree_sitter::Node,
1645        source: &str,
1646        _lang: LangId,
1647        tracked_names: &[String],
1648    ) -> Option<(String, String, u32, bool)> {
1649        let kind = node.kind();
1650        let line = node.start_position().row as u32 + 1;
1651
1652        match kind {
1653            "variable_declarator" => {
1654                // TS/JS: const x = <expr>
1655                let name_node = node.child_by_field_name("name")?;
1656                let value_node = node.child_by_field_name("value")?;
1657                let name_text = node_text(name_node, source);
1658                let value_text = node_text(value_node, source);
1659
1660                // Check if name is a destructuring pattern
1661                if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
1662                    // Check if value references a tracked name
1663                    if tracked_names.iter().any(|t| value_text.contains(t)) {
1664                        return Some((name_text.clone(), name_text, line, true));
1665                    }
1666                    return None;
1667                }
1668
1669                // Check if value references any tracked name
1670                if tracked_names.iter().any(|t| {
1671                    value_text == *t
1672                        || value_text.starts_with(&format!("{}.", t))
1673                        || value_text.starts_with(&format!("{}[", t))
1674                }) {
1675                    return Some((name_text, value_text, line, false));
1676                }
1677                None
1678            }
1679            "assignment_expression" | "augmented_assignment_expression" => {
1680                // TS/JS: x = <expr>
1681                let left = node.child_by_field_name("left")?;
1682                let right = node.child_by_field_name("right")?;
1683                let left_text = node_text(left, source);
1684                let right_text = node_text(right, source);
1685
1686                if tracked_names.iter().any(|t| right_text == *t) {
1687                    return Some((left_text, right_text, line, false));
1688                }
1689                None
1690            }
1691            "assignment" => {
1692                // Python: x = <expr>
1693                let left = node.child_by_field_name("left")?;
1694                let right = node.child_by_field_name("right")?;
1695                let left_text = node_text(left, source);
1696                let right_text = node_text(right, source);
1697
1698                if tracked_names.iter().any(|t| right_text == *t) {
1699                    return Some((left_text, right_text, line, false));
1700                }
1701                None
1702            }
1703            "let_declaration" | "short_var_declaration" => {
1704                // Rust / Go
1705                let left = node
1706                    .child_by_field_name("pattern")
1707                    .or_else(|| node.child_by_field_name("left"))?;
1708                let right = node
1709                    .child_by_field_name("value")
1710                    .or_else(|| node.child_by_field_name("right"))?;
1711                let left_text = node_text(left, source);
1712                let right_text = node_text(right, source);
1713
1714                if tracked_names.iter().any(|t| right_text == *t) {
1715                    return Some((left_text, right_text, line, false));
1716                }
1717                None
1718            }
1719            _ => None,
1720        }
1721    }
1722
1723    /// Check if a call expression uses a tracked name as an argument, and if so,
1724    /// resolve the callee and recurse into its body tracking the parameter name.
1725    #[allow(clippy::too_many_arguments)]
1726    fn check_call_for_data_flow(
1727        &mut self,
1728        node: tree_sitter::Node,
1729        source: &str,
1730        tracked_names: &[String],
1731        file: &Path,
1732        _symbol: &str,
1733        rel_file: &str,
1734        _lang: LangId,
1735        max_depth: usize,
1736        current_depth: usize,
1737        hops: &mut Vec<DataFlowHop>,
1738        depth_limited: &mut bool,
1739        visited: &mut HashSet<(PathBuf, String, String)>,
1740    ) {
1741        // Find the arguments node
1742        let args_node = find_child_by_kind(node, "arguments")
1743            .or_else(|| find_child_by_kind(node, "argument_list"));
1744
1745        let args_node = match args_node {
1746            Some(n) => n,
1747            None => return,
1748        };
1749
1750        // Collect argument texts and find which position a tracked name appears at
1751        let mut arg_positions: Vec<(usize, String)> = Vec::new(); // (position, tracked_name)
1752        let mut arg_idx = 0;
1753
1754        let mut cursor = args_node.walk();
1755        if cursor.goto_first_child() {
1756            loop {
1757                let child = cursor.node();
1758                let child_kind = child.kind();
1759
1760                // Skip punctuation (parentheses, commas)
1761                if child_kind == "(" || child_kind == ")" || child_kind == "," {
1762                    if !cursor.goto_next_sibling() {
1763                        break;
1764                    }
1765                    continue;
1766                }
1767
1768                let arg_text = node_text(child, source);
1769
1770                // Check for spread element — approximate
1771                if child_kind == "spread_element" || child_kind == "dictionary_splat" {
1772                    if tracked_names.iter().any(|t| arg_text.contains(t)) {
1773                        hops.push(DataFlowHop {
1774                            file: rel_file.to_string(),
1775                            symbol: _symbol.to_string(),
1776                            variable: arg_text,
1777                            line: child.start_position().row as u32 + 1,
1778                            flow_type: "parameter".to_string(),
1779                            approximate: true,
1780                        });
1781                    }
1782                    if !cursor.goto_next_sibling() {
1783                        break;
1784                    }
1785                    arg_idx += 1;
1786                    continue;
1787                }
1788
1789                if tracked_names.iter().any(|t| arg_text == *t) {
1790                    arg_positions.push((arg_idx, arg_text));
1791                }
1792
1793                arg_idx += 1;
1794                if !cursor.goto_next_sibling() {
1795                    break;
1796                }
1797            }
1798        }
1799
1800        if arg_positions.is_empty() {
1801            return;
1802        }
1803
1804        // Resolve the callee
1805        let (full_callee, short_callee) = extract_callee_names(node, source);
1806        let full_callee = match full_callee {
1807            Some(f) => f,
1808            None => return,
1809        };
1810        let short_callee = match short_callee {
1811            Some(s) => s,
1812            None => return,
1813        };
1814
1815        // Try to resolve cross-file edge
1816        let import_block = {
1817            match self.data.get(file) {
1818                Some(fd) => fd.import_block.clone(),
1819                None => return,
1820            }
1821        };
1822
1823        let edge = self.resolve_cross_file_edge(&full_callee, &short_callee, file, &import_block);
1824
1825        match edge {
1826            EdgeResolution::Resolved {
1827                file: target_file,
1828                symbol: target_symbol,
1829            } => {
1830                if current_depth + 1 > max_depth {
1831                    *depth_limited = true;
1832                    return;
1833                }
1834
1835                // Build target file to get parameter info
1836                if let Err(e) = self.build_file(&target_file) {
1837                    log::debug!(
1838                        "callgraph: skipping target file {}: {}",
1839                        target_file.display(),
1840                        e
1841                    );
1842                }
1843                let (params, target_line) = {
1844                    match self.lookup_file_data(&target_file) {
1845                        Some(fd) => {
1846                            let meta = fd.symbol_metadata.get(&target_symbol);
1847                            let sig = meta.and_then(|m| m.signature.clone());
1848                            let params = sig
1849                                .as_deref()
1850                                .map(|s| extract_parameters(s, fd.lang))
1851                                .unwrap_or_default();
1852                            let line = meta.map(|m| m.line).unwrap_or(1);
1853                            (params, line)
1854                        }
1855                        None => return,
1856                    }
1857                };
1858
1859                let target_rel = self.relative_path(&target_file);
1860
1861                for (pos, _tracked) in &arg_positions {
1862                    if let Some(param_name) = params.get(*pos) {
1863                        // Add parameter hop
1864                        hops.push(DataFlowHop {
1865                            file: target_rel.clone(),
1866                            symbol: target_symbol.clone(),
1867                            variable: param_name.clone(),
1868                            line: target_line,
1869                            flow_type: "parameter".to_string(),
1870                            approximate: false,
1871                        });
1872
1873                        // Recurse into callee's body tracking the parameter name
1874                        self.trace_data_inner(
1875                            &target_file.clone(),
1876                            &target_symbol.clone(),
1877                            param_name,
1878                            max_depth,
1879                            current_depth + 1,
1880                            hops,
1881                            depth_limited,
1882                            visited,
1883                        );
1884                    }
1885                }
1886            }
1887            EdgeResolution::Unresolved { callee_name } => {
1888                // Check if it's a same-file call
1889                let has_local = self
1890                    .data
1891                    .get(file)
1892                    .map(|fd| {
1893                        fd.calls_by_symbol.contains_key(&callee_name)
1894                            || fd.symbol_metadata.contains_key(&callee_name)
1895                    })
1896                    .unwrap_or(false);
1897
1898                if has_local {
1899                    // Same-file call — get param info
1900                    let (params, target_line) = {
1901                        let Some(fd) = self.data.get(file) else {
1902                            return;
1903                        };
1904                        let meta = fd.symbol_metadata.get(&callee_name);
1905                        let sig = meta.and_then(|m| m.signature.clone());
1906                        let params = sig
1907                            .as_deref()
1908                            .map(|s| extract_parameters(s, fd.lang))
1909                            .unwrap_or_default();
1910                        let line = meta.map(|m| m.line).unwrap_or(1);
1911                        (params, line)
1912                    };
1913
1914                    let file_rel = self.relative_path(file);
1915
1916                    for (pos, _tracked) in &arg_positions {
1917                        if let Some(param_name) = params.get(*pos) {
1918                            hops.push(DataFlowHop {
1919                                file: file_rel.clone(),
1920                                symbol: callee_name.clone(),
1921                                variable: param_name.clone(),
1922                                line: target_line,
1923                                flow_type: "parameter".to_string(),
1924                                approximate: false,
1925                            });
1926
1927                            // Recurse into same-file function
1928                            self.trace_data_inner(
1929                                file,
1930                                &callee_name.clone(),
1931                                param_name,
1932                                max_depth,
1933                                current_depth + 1,
1934                                hops,
1935                                depth_limited,
1936                                visited,
1937                            );
1938                        }
1939                    }
1940                } else {
1941                    // Truly unresolved — approximate hop
1942                    for (_pos, tracked) in &arg_positions {
1943                        hops.push(DataFlowHop {
1944                            file: self.relative_path(file),
1945                            symbol: callee_name.clone(),
1946                            variable: tracked.clone(),
1947                            line: node.start_position().row as u32 + 1,
1948                            flow_type: "parameter".to_string(),
1949                            approximate: true,
1950                        });
1951                    }
1952                }
1953            }
1954        }
1955    }
1956
1957    /// Read a single source line (1-based) from a file, trimmed.
1958    fn read_source_line(&self, path: &Path, line: u32) -> Option<String> {
1959        let content = std::fs::read_to_string(path).ok()?;
1960        content
1961            .lines()
1962            .nth(line.saturating_sub(1) as usize)
1963            .map(|l| l.trim().to_string())
1964    }
1965
1966    /// Recursively collect callers up to the given depth.
1967    fn collect_callers_recursive(
1968        &self,
1969        file: &Path,
1970        symbol: &str,
1971        max_depth: usize,
1972        current_depth: usize,
1973        visited: &mut HashSet<(PathBuf, SharedStr)>,
1974        result: &mut Vec<CallerSite>,
1975    ) {
1976        if current_depth >= max_depth {
1977            return;
1978        }
1979
1980        // Canonicalize for consistent reverse index lookup
1981        let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
1982        let key_symbol: SharedStr = Arc::from(symbol);
1983        if !visited.insert((canon.clone(), Arc::clone(&key_symbol))) {
1984            return; // cycle detection
1985        }
1986
1987        if let Some(sites) = self.reverse_sites(&canon, key_symbol.as_ref()) {
1988            for site in sites {
1989                result.push(CallerSite {
1990                    caller_file: site.caller_file.as_ref().clone(),
1991                    caller_symbol: site.caller_symbol.to_string(),
1992                    line: site.line,
1993                    col: site.col,
1994                    resolved: site.resolved,
1995                });
1996                // Recurse: find callers of the caller
1997                if current_depth + 1 < max_depth {
1998                    self.collect_callers_recursive(
1999                        site.caller_file.as_ref(),
2000                        site.caller_symbol.as_ref(),
2001                        max_depth,
2002                        current_depth + 1,
2003                        visited,
2004                        result,
2005                    );
2006                }
2007            }
2008        }
2009    }
2010
2011    /// Invalidate a file: remove its cached data and clear the reverse index.
2012    ///
2013    /// Called by the file watcher when a file changes on disk. The reverse
2014    /// index is rebuilt lazily on the next `callers_of` call.
2015    pub fn invalidate_file(&mut self, path: &Path) {
2016        // Remove from data cache (try both as-is and canonicalized)
2017        self.data.remove(path);
2018        if let Ok(canon) = self.canonicalize(path) {
2019            self.data.remove(&canon);
2020        }
2021        // Clear the reverse index — it's stale
2022        self.reverse_index = None;
2023        // Clear project_files cache for create/remove events
2024        self.project_files = None;
2025    }
2026
2027    /// Return a path relative to the project root, or the absolute path if
2028    /// it's outside the project.
2029    fn relative_path(&self, path: &Path) -> String {
2030        path.strip_prefix(&self.project_root)
2031            .unwrap_or(path)
2032            .display()
2033            .to_string()
2034    }
2035
2036    /// Canonicalize a path, falling back to the original if canonicalization fails.
2037    fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
2038        // If the path is relative, resolve it against project_root
2039        let full_path = if path.is_relative() {
2040            self.project_root.join(path)
2041        } else {
2042            path.to_path_buf()
2043        };
2044
2045        // Try canonicalize, fall back to the full path
2046        Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
2047    }
2048
2049    /// Look up cached file data, trying both the given path and its
2050    /// canonicalized form. Needed because `build_reverse_index` may store
2051    /// data under raw walker paths while CallerSite uses canonical paths.
2052    fn lookup_file_data(&self, path: &Path) -> Option<&FileCallData> {
2053        if let Some(fd) = self.data.get(path) {
2054            return Some(fd);
2055        }
2056        // Try canonical
2057        let canon = std::fs::canonicalize(path).ok()?;
2058        self.data.get(&canon).or_else(|| {
2059            // Try non-canonical forms stored by the walker
2060            self.data.iter().find_map(|(k, v)| {
2061                if std::fs::canonicalize(k).ok().as_ref() == Some(&canon) {
2062                    Some(v)
2063                } else {
2064                    None
2065                }
2066            })
2067        })
2068    }
2069}
2070
2071// ---------------------------------------------------------------------------
2072// File-level building
2073// ---------------------------------------------------------------------------
2074
2075/// Build call data for a single file.
2076fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
2077    let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
2078        message: format!("unsupported file for call graph: {}", path.display()),
2079    })?;
2080
2081    let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
2082        path: format!("{}: {}", path.display(), e),
2083    })?;
2084
2085    let grammar = grammar_for(lang);
2086    let mut parser = Parser::new();
2087    parser
2088        .set_language(&grammar)
2089        .map_err(|e| AftError::ParseError {
2090            message: format!("grammar init failed for {:?}: {}", lang, e),
2091        })?;
2092
2093    let tree = parser
2094        .parse(&source, None)
2095        .ok_or_else(|| AftError::ParseError {
2096            message: format!("parse failed for {}", path.display()),
2097        })?;
2098
2099    // Parse imports
2100    let import_block = imports::parse_imports(&source, &tree, lang);
2101
2102    // Get symbols (for call site extraction and export detection)
2103    let symbols = crate::parser::extract_symbols_from_tree(&source, &tree, lang)?;
2104
2105    // Build calls_by_symbol
2106    let mut calls_by_symbol: HashMap<String, Vec<CallSite>> = HashMap::new();
2107    let root = tree.root_node();
2108
2109    for sym in &symbols {
2110        let byte_start = line_col_to_byte(&source, sym.range.start_line, sym.range.start_col);
2111        let byte_end = line_col_to_byte(&source, sym.range.end_line, sym.range.end_col);
2112
2113        let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2114
2115        let sites: Vec<CallSite> = raw_calls
2116            .into_iter()
2117            .filter(|(_, short, _)| *short != sym.name) // exclude self-references
2118            .map(|(full, short, line)| CallSite {
2119                callee_name: short,
2120                full_callee: full,
2121                line,
2122                byte_start,
2123                byte_end,
2124            })
2125            .collect();
2126
2127        if !sites.is_empty() {
2128            calls_by_symbol.insert(sym.name.clone(), sites);
2129        }
2130    }
2131
2132    // Collect exported symbol names
2133    let exported_symbols: Vec<String> = symbols
2134        .iter()
2135        .filter(|s| s.exported)
2136        .map(|s| s.name.clone())
2137        .collect();
2138
2139    // Build per-symbol metadata for entry point detection
2140    let symbol_metadata: HashMap<String, SymbolMeta> = symbols
2141        .iter()
2142        .map(|s| {
2143            (
2144                s.name.clone(),
2145                SymbolMeta {
2146                    kind: s.kind.clone(),
2147                    exported: s.exported,
2148                    signature: s.signature.clone(),
2149                    line: s.range.start_line + 1,
2150                    range: s.range.clone(),
2151                },
2152            )
2153        })
2154        .collect();
2155
2156    Ok(FileCallData {
2157        calls_by_symbol,
2158        exported_symbols,
2159        symbol_metadata,
2160        import_block,
2161        lang,
2162    })
2163}
2164
2165fn get_symbol_meta_from_data(file_data: &FileCallData, symbol_name: &str) -> (u32, Option<String>) {
2166    file_data
2167        .symbol_metadata
2168        .get(symbol_name)
2169        .map(|meta| (meta.line, meta.signature.clone()))
2170        .unwrap_or((1, None))
2171}
2172
2173/// Get symbol metadata (line, signature) from a file.
2174fn get_symbol_meta(path: &Path, symbol_name: &str) -> (u32, Option<String>) {
2175    let provider = crate::parser::TreeSitterProvider::new();
2176    match provider.list_symbols(path) {
2177        Ok(symbols) => {
2178            for s in &symbols {
2179                if s.name == symbol_name {
2180                    return (s.range.start_line + 1, s.signature.clone());
2181                }
2182            }
2183            (1, None)
2184        }
2185        Err(_) => (1, None),
2186    }
2187}
2188
2189// ---------------------------------------------------------------------------
2190// Data flow tracking helpers
2191// ---------------------------------------------------------------------------
2192
2193/// Get the text of a tree-sitter node from the source.
2194fn node_text(node: tree_sitter::Node, source: &str) -> String {
2195    source[node.start_byte()..node.end_byte()].to_string()
2196}
2197
2198/// Find the smallest node that fully covers a byte range.
2199fn find_node_covering_range(
2200    root: tree_sitter::Node,
2201    start: usize,
2202    end: usize,
2203) -> Option<tree_sitter::Node> {
2204    let mut best = None;
2205    let mut cursor = root.walk();
2206
2207    fn walk_covering<'a>(
2208        cursor: &mut tree_sitter::TreeCursor<'a>,
2209        start: usize,
2210        end: usize,
2211        best: &mut Option<tree_sitter::Node<'a>>,
2212    ) {
2213        let node = cursor.node();
2214        if node.start_byte() <= start && node.end_byte() >= end {
2215            *best = Some(node);
2216            if cursor.goto_first_child() {
2217                loop {
2218                    walk_covering(cursor, start, end, best);
2219                    if !cursor.goto_next_sibling() {
2220                        break;
2221                    }
2222                }
2223                cursor.goto_parent();
2224            }
2225        }
2226    }
2227
2228    walk_covering(&mut cursor, start, end, &mut best);
2229    best
2230}
2231
2232/// Find a direct child node by kind name.
2233fn find_child_by_kind<'a>(
2234    node: tree_sitter::Node<'a>,
2235    kind: &str,
2236) -> Option<tree_sitter::Node<'a>> {
2237    let mut cursor = node.walk();
2238    if cursor.goto_first_child() {
2239        loop {
2240            if cursor.node().kind() == kind {
2241                return Some(cursor.node());
2242            }
2243            if !cursor.goto_next_sibling() {
2244                break;
2245            }
2246        }
2247    }
2248    None
2249}
2250
2251/// Extract full and short callee names from a call_expression node.
2252fn extract_callee_names(node: tree_sitter::Node, source: &str) -> (Option<String>, Option<String>) {
2253    // The "function" field holds the callee
2254    let callee = match node.child_by_field_name("function") {
2255        Some(c) => c,
2256        None => return (None, None),
2257    };
2258
2259    let full = node_text(callee, source);
2260    let short = if full.contains('.') {
2261        full.rsplit('.').next().unwrap_or(&full).to_string()
2262    } else {
2263        full.clone()
2264    };
2265
2266    (Some(full), Some(short))
2267}
2268
2269// ---------------------------------------------------------------------------
2270// Module path resolution
2271// ---------------------------------------------------------------------------
2272
2273/// Resolve a module path (e.g. './utils') relative to a directory.
2274///
2275/// Tries common file extensions for TypeScript/JavaScript projects.
2276pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2277    // Only handle relative imports
2278    if !module_path.starts_with('.') {
2279        return None;
2280    }
2281
2282    let base = from_dir.join(module_path);
2283
2284    // Try exact path first
2285    if base.is_file() {
2286        return Some(std::fs::canonicalize(&base).unwrap_or(base));
2287    }
2288
2289    // Try common extensions
2290    let extensions = [".ts", ".tsx", ".js", ".jsx"];
2291    for ext in &extensions {
2292        let with_ext = base.with_extension(ext.trim_start_matches('.'));
2293        if with_ext.is_file() {
2294            return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
2295        }
2296    }
2297
2298    // Try as directory with index file
2299    if base.is_dir() {
2300        if let Some(index) = find_index_file(&base) {
2301            return Some(index);
2302        }
2303    }
2304
2305    None
2306}
2307
2308/// Find an index file in a directory.
2309fn find_index_file(dir: &Path) -> Option<PathBuf> {
2310    let candidates = ["index.ts", "index.tsx", "index.js", "index.jsx"];
2311    for name in &candidates {
2312        let p = dir.join(name);
2313        if p.is_file() {
2314            return Some(std::fs::canonicalize(&p).unwrap_or(p));
2315        }
2316    }
2317    None
2318}
2319
2320/// Resolve an aliased import: `import { foo as bar } from './utils'`
2321/// where `local_name` is "bar". Returns `(original_name, resolved_file_path)`.
2322fn resolve_aliased_import(
2323    local_name: &str,
2324    import_block: &ImportBlock,
2325    caller_dir: &Path,
2326) -> Option<(String, PathBuf)> {
2327    for imp in &import_block.imports {
2328        // Parse the raw text to find "as <alias>" patterns
2329        // This handles: import { foo as bar, baz as qux } from './mod'
2330        if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
2331            if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
2332                return Some((original, resolved_path));
2333            }
2334        }
2335    }
2336    None
2337}
2338
2339/// Parse import raw text to find the original name for an alias.
2340/// Given raw text like `import { foo as bar, baz } from './utils'` and
2341/// local_name "bar", returns Some("foo").
2342fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
2343    // Look for pattern: <original> as <alias>
2344    // This is a simple text-based search; handles the common TS/JS pattern
2345    let search = format!(" as {}", local_name);
2346    if let Some(pos) = raw_import.find(&search) {
2347        // Walk backwards from `pos` to find the original name
2348        let before = &raw_import[..pos];
2349        // The original name is the last word-like token before " as "
2350        let original = before
2351            .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
2352            .find(|s| !s.is_empty())?;
2353        return Some(original.to_string());
2354    }
2355    None
2356}
2357
2358// ---------------------------------------------------------------------------
2359// Worktree file discovery
2360// ---------------------------------------------------------------------------
2361
2362/// Walk project files respecting .gitignore, excluding common non-source dirs.
2363///
2364/// Returns an iterator of file paths for supported source file types.
2365pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
2366    use ignore::WalkBuilder;
2367
2368    let walker = WalkBuilder::new(root)
2369        .hidden(true)         // skip hidden files/dirs
2370        .git_ignore(true)     // respect .gitignore
2371        .git_global(true)     // respect global gitignore
2372        .git_exclude(true)    // respect .git/info/exclude
2373        .filter_entry(|entry| {
2374            let name = entry.file_name().to_string_lossy();
2375            // Always exclude these directories regardless of .gitignore
2376            if entry.file_type().map_or(false, |ft| ft.is_dir()) {
2377                return !matches!(
2378                    name.as_ref(),
2379                    "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
2380                        | ".tox" | "dist" | "build"
2381                );
2382            }
2383            true
2384        })
2385        .build();
2386
2387    walker
2388        .filter_map(|entry| entry.ok())
2389        .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
2390        .filter(|entry| detect_language(entry.path()).is_some())
2391        .map(|entry| entry.into_path())
2392}
2393
2394// ---------------------------------------------------------------------------
2395// Tests
2396// ---------------------------------------------------------------------------
2397
2398#[cfg(test)]
2399mod tests {
2400    use super::*;
2401    use std::fs;
2402    use tempfile::TempDir;
2403
2404    /// Create a temp directory with TypeScript files for testing.
2405    fn setup_ts_project() -> TempDir {
2406        let dir = TempDir::new().unwrap();
2407
2408        // main.ts: imports from utils and calls functions
2409        fs::write(
2410            dir.path().join("main.ts"),
2411            r#"import { helper, compute } from './utils';
2412import * as math from './math';
2413
2414export function main() {
2415    const a = helper(1);
2416    const b = compute(a, 2);
2417    const c = math.add(a, b);
2418    return c;
2419}
2420"#,
2421        )
2422        .unwrap();
2423
2424        // utils.ts: defines helper and compute, imports from helpers
2425        fs::write(
2426            dir.path().join("utils.ts"),
2427            r#"import { double } from './helpers';
2428
2429export function helper(x: number): number {
2430    return double(x);
2431}
2432
2433export function compute(a: number, b: number): number {
2434    return a + b;
2435}
2436"#,
2437        )
2438        .unwrap();
2439
2440        // helpers.ts: defines double
2441        fs::write(
2442            dir.path().join("helpers.ts"),
2443            r#"export function double(x: number): number {
2444    return x * 2;
2445}
2446
2447export function triple(x: number): number {
2448    return x * 3;
2449}
2450"#,
2451        )
2452        .unwrap();
2453
2454        // math.ts: defines add (for namespace import test)
2455        fs::write(
2456            dir.path().join("math.ts"),
2457            r#"export function add(a: number, b: number): number {
2458    return a + b;
2459}
2460
2461export function subtract(a: number, b: number): number {
2462    return a - b;
2463}
2464"#,
2465        )
2466        .unwrap();
2467
2468        dir
2469    }
2470
2471    /// Create a project with import aliasing.
2472    fn setup_alias_project() -> TempDir {
2473        let dir = TempDir::new().unwrap();
2474
2475        fs::write(
2476            dir.path().join("main.ts"),
2477            r#"import { helper as h } from './utils';
2478
2479export function main() {
2480    return h(42);
2481}
2482"#,
2483        )
2484        .unwrap();
2485
2486        fs::write(
2487            dir.path().join("utils.ts"),
2488            r#"export function helper(x: number): number {
2489    return x + 1;
2490}
2491"#,
2492        )
2493        .unwrap();
2494
2495        dir
2496    }
2497
2498    /// Create a project with a cycle: A → B → A.
2499    fn setup_cycle_project() -> TempDir {
2500        let dir = TempDir::new().unwrap();
2501
2502        fs::write(
2503            dir.path().join("a.ts"),
2504            r#"import { funcB } from './b';
2505
2506export function funcA() {
2507    return funcB();
2508}
2509"#,
2510        )
2511        .unwrap();
2512
2513        fs::write(
2514            dir.path().join("b.ts"),
2515            r#"import { funcA } from './a';
2516
2517export function funcB() {
2518    return funcA();
2519}
2520"#,
2521        )
2522        .unwrap();
2523
2524        dir
2525    }
2526
2527    // --- Single-file call extraction ---
2528
2529    #[test]
2530    fn callgraph_single_file_call_extraction() {
2531        let dir = setup_ts_project();
2532        let mut graph = CallGraph::new(dir.path().to_path_buf());
2533
2534        let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
2535        let main_calls = &file_data.calls_by_symbol["main"];
2536
2537        let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
2538        assert!(
2539            callee_names.contains(&"helper"),
2540            "main should call helper, got: {:?}",
2541            callee_names
2542        );
2543        assert!(
2544            callee_names.contains(&"compute"),
2545            "main should call compute, got: {:?}",
2546            callee_names
2547        );
2548        assert!(
2549            callee_names.contains(&"add"),
2550            "main should call math.add (short name: add), got: {:?}",
2551            callee_names
2552        );
2553    }
2554
2555    #[test]
2556    fn callgraph_file_data_has_exports() {
2557        let dir = setup_ts_project();
2558        let mut graph = CallGraph::new(dir.path().to_path_buf());
2559
2560        let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
2561        assert!(
2562            file_data.exported_symbols.contains(&"helper".to_string()),
2563            "utils.ts should export helper, got: {:?}",
2564            file_data.exported_symbols
2565        );
2566        assert!(
2567            file_data.exported_symbols.contains(&"compute".to_string()),
2568            "utils.ts should export compute, got: {:?}",
2569            file_data.exported_symbols
2570        );
2571    }
2572
2573    // --- Cross-file resolution ---
2574
2575    #[test]
2576    fn callgraph_resolve_direct_import() {
2577        let dir = setup_ts_project();
2578        let mut graph = CallGraph::new(dir.path().to_path_buf());
2579
2580        let main_path = dir.path().join("main.ts");
2581        let file_data = graph.build_file(&main_path).unwrap();
2582        let import_block = file_data.import_block.clone();
2583
2584        let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
2585        match edge {
2586            EdgeResolution::Resolved { file, symbol } => {
2587                assert!(
2588                    file.ends_with("utils.ts"),
2589                    "helper should resolve to utils.ts, got: {:?}",
2590                    file
2591                );
2592                assert_eq!(symbol, "helper");
2593            }
2594            EdgeResolution::Unresolved { callee_name } => {
2595                panic!("Expected resolved, got unresolved: {}", callee_name);
2596            }
2597        }
2598    }
2599
2600    #[test]
2601    fn callgraph_resolve_namespace_import() {
2602        let dir = setup_ts_project();
2603        let mut graph = CallGraph::new(dir.path().to_path_buf());
2604
2605        let main_path = dir.path().join("main.ts");
2606        let file_data = graph.build_file(&main_path).unwrap();
2607        let import_block = file_data.import_block.clone();
2608
2609        let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
2610        match edge {
2611            EdgeResolution::Resolved { file, symbol } => {
2612                assert!(
2613                    file.ends_with("math.ts"),
2614                    "math.add should resolve to math.ts, got: {:?}",
2615                    file
2616                );
2617                assert_eq!(symbol, "add");
2618            }
2619            EdgeResolution::Unresolved { callee_name } => {
2620                panic!("Expected resolved, got unresolved: {}", callee_name);
2621            }
2622        }
2623    }
2624
2625    #[test]
2626    fn callgraph_resolve_aliased_import() {
2627        let dir = setup_alias_project();
2628        let mut graph = CallGraph::new(dir.path().to_path_buf());
2629
2630        let main_path = dir.path().join("main.ts");
2631        let file_data = graph.build_file(&main_path).unwrap();
2632        let import_block = file_data.import_block.clone();
2633
2634        let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
2635        match edge {
2636            EdgeResolution::Resolved { file, symbol } => {
2637                assert!(
2638                    file.ends_with("utils.ts"),
2639                    "h (alias for helper) should resolve to utils.ts, got: {:?}",
2640                    file
2641                );
2642                assert_eq!(symbol, "helper");
2643            }
2644            EdgeResolution::Unresolved { callee_name } => {
2645                panic!("Expected resolved, got unresolved: {}", callee_name);
2646            }
2647        }
2648    }
2649
2650    #[test]
2651    fn callgraph_unresolved_edge_marked() {
2652        let dir = setup_ts_project();
2653        let mut graph = CallGraph::new(dir.path().to_path_buf());
2654
2655        let main_path = dir.path().join("main.ts");
2656        let file_data = graph.build_file(&main_path).unwrap();
2657        let import_block = file_data.import_block.clone();
2658
2659        let edge =
2660            graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
2661        assert_eq!(
2662            edge,
2663            EdgeResolution::Unresolved {
2664                callee_name: "unknownFunc".to_string()
2665            },
2666            "Unknown callee should be unresolved"
2667        );
2668    }
2669
2670    // --- Cycle detection ---
2671
2672    #[test]
2673    fn callgraph_cycle_detection_stops() {
2674        let dir = setup_cycle_project();
2675        let mut graph = CallGraph::new(dir.path().to_path_buf());
2676
2677        // This should NOT infinite loop
2678        let tree = graph
2679            .forward_tree(&dir.path().join("a.ts"), "funcA", 10)
2680            .unwrap();
2681
2682        assert_eq!(tree.name, "funcA");
2683        assert!(tree.resolved);
2684
2685        // funcA calls funcB, funcB calls funcA (cycle), so the depth should be bounded
2686        // The tree should have children but not infinitely deep
2687        fn count_depth(node: &CallTreeNode) -> usize {
2688            if node.children.is_empty() {
2689                1
2690            } else {
2691                1 + node.children.iter().map(count_depth).max().unwrap_or(0)
2692            }
2693        }
2694
2695        let depth = count_depth(&tree);
2696        assert!(
2697            depth <= 4,
2698            "Cycle should be detected and bounded, depth was: {}",
2699            depth
2700        );
2701    }
2702
2703    // --- Depth limiting ---
2704
2705    #[test]
2706    fn callgraph_depth_limit_truncates() {
2707        let dir = setup_ts_project();
2708        let mut graph = CallGraph::new(dir.path().to_path_buf());
2709
2710        // main → helper → double, main → compute
2711        // With depth 1, we should see direct callees but not their children
2712        let tree = graph
2713            .forward_tree(&dir.path().join("main.ts"), "main", 1)
2714            .unwrap();
2715
2716        assert_eq!(tree.name, "main");
2717
2718        // At depth 1, children should exist (direct calls) but their children should be empty
2719        for child in &tree.children {
2720            assert!(
2721                child.children.is_empty(),
2722                "At depth 1, child '{}' should have no children, got {:?}",
2723                child.name,
2724                child.children.len()
2725            );
2726        }
2727    }
2728
2729    #[test]
2730    fn callgraph_depth_zero_no_children() {
2731        let dir = setup_ts_project();
2732        let mut graph = CallGraph::new(dir.path().to_path_buf());
2733
2734        let tree = graph
2735            .forward_tree(&dir.path().join("main.ts"), "main", 0)
2736            .unwrap();
2737
2738        assert_eq!(tree.name, "main");
2739        assert!(
2740            tree.children.is_empty(),
2741            "At depth 0, should have no children"
2742        );
2743    }
2744
2745    // --- Forward tree cross-file ---
2746
2747    #[test]
2748    fn callgraph_forward_tree_cross_file() {
2749        let dir = setup_ts_project();
2750        let mut graph = CallGraph::new(dir.path().to_path_buf());
2751
2752        // main → helper (in utils.ts) → double (in helpers.ts)
2753        let tree = graph
2754            .forward_tree(&dir.path().join("main.ts"), "main", 5)
2755            .unwrap();
2756
2757        assert_eq!(tree.name, "main");
2758        assert!(tree.resolved);
2759
2760        // Find the helper child
2761        let helper_child = tree.children.iter().find(|c| c.name == "helper");
2762        assert!(
2763            helper_child.is_some(),
2764            "main should have helper as child, children: {:?}",
2765            tree.children.iter().map(|c| &c.name).collect::<Vec<_>>()
2766        );
2767
2768        let helper = helper_child.unwrap();
2769        assert!(
2770            helper.file.ends_with("utils.ts") || helper.file == "utils.ts",
2771            "helper should be in utils.ts, got: {}",
2772            helper.file
2773        );
2774
2775        // helper should call double (in helpers.ts)
2776        let double_child = helper.children.iter().find(|c| c.name == "double");
2777        assert!(
2778            double_child.is_some(),
2779            "helper should call double, children: {:?}",
2780            helper.children.iter().map(|c| &c.name).collect::<Vec<_>>()
2781        );
2782
2783        let double = double_child.unwrap();
2784        assert!(
2785            double.file.ends_with("helpers.ts") || double.file == "helpers.ts",
2786            "double should be in helpers.ts, got: {}",
2787            double.file
2788        );
2789    }
2790
2791    // --- Worktree walker ---
2792
2793    #[test]
2794    fn callgraph_walker_excludes_gitignored() {
2795        let dir = TempDir::new().unwrap();
2796
2797        // Create a .gitignore
2798        fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
2799
2800        // Create files
2801        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
2802        fs::create_dir(dir.path().join("ignored_dir")).unwrap();
2803        fs::write(
2804            dir.path().join("ignored_dir").join("secret.ts"),
2805            "export function secret() {}",
2806        )
2807        .unwrap();
2808
2809        // Also create node_modules (should always be excluded)
2810        fs::create_dir(dir.path().join("node_modules")).unwrap();
2811        fs::write(
2812            dir.path().join("node_modules").join("dep.ts"),
2813            "export function dep() {}",
2814        )
2815        .unwrap();
2816
2817        // Init git repo for .gitignore to work
2818        std::process::Command::new("git")
2819            .args(["init"])
2820            .current_dir(dir.path())
2821            .output()
2822            .unwrap();
2823
2824        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
2825        let file_names: Vec<String> = files
2826            .iter()
2827            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
2828            .collect();
2829
2830        assert!(
2831            file_names.contains(&"main.ts".to_string()),
2832            "Should include main.ts, got: {:?}",
2833            file_names
2834        );
2835        assert!(
2836            !file_names.contains(&"secret.ts".to_string()),
2837            "Should exclude gitignored secret.ts, got: {:?}",
2838            file_names
2839        );
2840        assert!(
2841            !file_names.contains(&"dep.ts".to_string()),
2842            "Should exclude node_modules, got: {:?}",
2843            file_names
2844        );
2845    }
2846
2847    #[test]
2848    fn callgraph_walker_only_source_files() {
2849        let dir = TempDir::new().unwrap();
2850
2851        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
2852        fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
2853        fs::write(dir.path().join("data.json"), "{}").unwrap();
2854
2855        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
2856        let file_names: Vec<String> = files
2857            .iter()
2858            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
2859            .collect();
2860
2861        assert!(file_names.contains(&"main.ts".to_string()));
2862        assert!(
2863            file_names.contains(&"readme.md".to_string()),
2864            "Markdown is now a supported source language"
2865        );
2866        assert!(
2867            file_names.contains(&"data.json".to_string()),
2868            "JSON is now a supported source language"
2869        );
2870    }
2871
2872    // --- find_alias_original ---
2873
2874    #[test]
2875    fn callgraph_find_alias_original_simple() {
2876        let raw = "import { foo as bar } from './utils';";
2877        assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
2878    }
2879
2880    #[test]
2881    fn callgraph_find_alias_original_multiple() {
2882        let raw = "import { foo as bar, baz as qux } from './utils';";
2883        assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
2884        assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
2885    }
2886
2887    #[test]
2888    fn callgraph_find_alias_no_match() {
2889        let raw = "import { foo } from './utils';";
2890        assert_eq!(find_alias_original(raw, "foo"), None);
2891    }
2892
2893    // --- Reverse callers ---
2894
2895    #[test]
2896    fn callgraph_callers_of_direct() {
2897        let dir = setup_ts_project();
2898        let mut graph = CallGraph::new(dir.path().to_path_buf());
2899
2900        // helpers.ts:double is called by utils.ts:helper
2901        let result = graph
2902            .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
2903            .unwrap();
2904
2905        assert_eq!(result.symbol, "double");
2906        assert!(result.total_callers > 0, "double should have callers");
2907        assert!(result.scanned_files > 0, "should have scanned files");
2908
2909        // Find the caller from utils.ts
2910        let utils_group = result.callers.iter().find(|g| g.file.contains("utils.ts"));
2911        assert!(
2912            utils_group.is_some(),
2913            "double should be called from utils.ts, groups: {:?}",
2914            result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
2915        );
2916
2917        let group = utils_group.unwrap();
2918        let helper_caller = group.callers.iter().find(|c| c.symbol == "helper");
2919        assert!(
2920            helper_caller.is_some(),
2921            "double should be called by helper, callers: {:?}",
2922            group.callers.iter().map(|c| &c.symbol).collect::<Vec<_>>()
2923        );
2924    }
2925
2926    #[test]
2927    fn callgraph_callers_of_no_callers() {
2928        let dir = setup_ts_project();
2929        let mut graph = CallGraph::new(dir.path().to_path_buf());
2930
2931        // main.ts:main is the entry point — nothing calls it
2932        let result = graph
2933            .callers_of(&dir.path().join("main.ts"), "main", 1, usize::MAX)
2934            .unwrap();
2935
2936        assert_eq!(result.symbol, "main");
2937        assert_eq!(result.total_callers, 0, "main should have no callers");
2938        assert!(result.callers.is_empty());
2939    }
2940
2941    #[test]
2942    fn callgraph_callers_recursive_depth() {
2943        let dir = setup_ts_project();
2944        let mut graph = CallGraph::new(dir.path().to_path_buf());
2945
2946        // helpers.ts:double is called by utils.ts:helper
2947        // utils.ts:helper is called by main.ts:main
2948        // With depth=2, we should see both direct and transitive callers
2949        let result = graph
2950            .callers_of(&dir.path().join("helpers.ts"), "double", 2, usize::MAX)
2951            .unwrap();
2952
2953        assert!(
2954            result.total_callers >= 2,
2955            "with depth 2, double should have >= 2 callers (direct + transitive), got {}",
2956            result.total_callers
2957        );
2958
2959        // Should include caller from main.ts (transitive: main → helper → double)
2960        let main_group = result.callers.iter().find(|g| g.file.contains("main.ts"));
2961        assert!(
2962            main_group.is_some(),
2963            "recursive callers should include main.ts, groups: {:?}",
2964            result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
2965        );
2966    }
2967
2968    #[test]
2969    fn callgraph_invalidate_file_clears_reverse_index() {
2970        let dir = setup_ts_project();
2971        let mut graph = CallGraph::new(dir.path().to_path_buf());
2972
2973        // Build callers to populate the reverse index
2974        let _ = graph
2975            .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
2976            .unwrap();
2977        assert!(
2978            graph.reverse_index.is_some(),
2979            "reverse index should be built"
2980        );
2981
2982        // Invalidate a file
2983        graph.invalidate_file(&dir.path().join("utils.ts"));
2984
2985        // Reverse index should be cleared
2986        assert!(
2987            graph.reverse_index.is_none(),
2988            "invalidate_file should clear reverse index"
2989        );
2990        // Data cache for the file should be cleared
2991        let canon = std::fs::canonicalize(dir.path().join("utils.ts")).unwrap();
2992        assert!(
2993            !graph.data.contains_key(&canon),
2994            "invalidate_file should remove file from data cache"
2995        );
2996        // Project files should be cleared
2997        assert!(
2998            graph.project_files.is_none(),
2999            "invalidate_file should clear project_files"
3000        );
3001    }
3002
3003    // --- is_entry_point ---
3004
3005    #[test]
3006    fn is_entry_point_exported_function() {
3007        assert!(is_entry_point(
3008            "handleRequest",
3009            &SymbolKind::Function,
3010            true,
3011            LangId::TypeScript
3012        ));
3013    }
3014
3015    #[test]
3016    fn is_entry_point_exported_method_is_not_entry() {
3017        // Methods are class members, not standalone entry points
3018        assert!(!is_entry_point(
3019            "handleRequest",
3020            &SymbolKind::Method,
3021            true,
3022            LangId::TypeScript
3023        ));
3024    }
3025
3026    #[test]
3027    fn is_entry_point_main_init_patterns() {
3028        for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
3029            assert!(
3030                is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
3031                "{} should be an entry point",
3032                name
3033            );
3034        }
3035    }
3036
3037    #[test]
3038    fn is_entry_point_test_patterns_ts() {
3039        assert!(is_entry_point(
3040            "describe",
3041            &SymbolKind::Function,
3042            false,
3043            LangId::TypeScript
3044        ));
3045        assert!(is_entry_point(
3046            "it",
3047            &SymbolKind::Function,
3048            false,
3049            LangId::TypeScript
3050        ));
3051        assert!(is_entry_point(
3052            "test",
3053            &SymbolKind::Function,
3054            false,
3055            LangId::TypeScript
3056        ));
3057        assert!(is_entry_point(
3058            "testValidation",
3059            &SymbolKind::Function,
3060            false,
3061            LangId::TypeScript
3062        ));
3063        assert!(is_entry_point(
3064            "specHelper",
3065            &SymbolKind::Function,
3066            false,
3067            LangId::TypeScript
3068        ));
3069    }
3070
3071    #[test]
3072    fn is_entry_point_test_patterns_python() {
3073        assert!(is_entry_point(
3074            "test_login",
3075            &SymbolKind::Function,
3076            false,
3077            LangId::Python
3078        ));
3079        assert!(is_entry_point(
3080            "setUp",
3081            &SymbolKind::Function,
3082            false,
3083            LangId::Python
3084        ));
3085        assert!(is_entry_point(
3086            "tearDown",
3087            &SymbolKind::Function,
3088            false,
3089            LangId::Python
3090        ));
3091        // "testSomething" should NOT match Python (needs test_ prefix)
3092        assert!(!is_entry_point(
3093            "testSomething",
3094            &SymbolKind::Function,
3095            false,
3096            LangId::Python
3097        ));
3098    }
3099
3100    #[test]
3101    fn is_entry_point_test_patterns_rust() {
3102        assert!(is_entry_point(
3103            "test_parse",
3104            &SymbolKind::Function,
3105            false,
3106            LangId::Rust
3107        ));
3108        assert!(!is_entry_point(
3109            "TestSomething",
3110            &SymbolKind::Function,
3111            false,
3112            LangId::Rust
3113        ));
3114    }
3115
3116    #[test]
3117    fn is_entry_point_test_patterns_go() {
3118        assert!(is_entry_point(
3119            "TestParsing",
3120            &SymbolKind::Function,
3121            false,
3122            LangId::Go
3123        ));
3124        // lowercase test should NOT match Go (needs uppercase Test prefix)
3125        assert!(!is_entry_point(
3126            "testParsing",
3127            &SymbolKind::Function,
3128            false,
3129            LangId::Go
3130        ));
3131    }
3132
3133    #[test]
3134    fn is_entry_point_non_exported_non_main_is_not_entry() {
3135        assert!(!is_entry_point(
3136            "helperUtil",
3137            &SymbolKind::Function,
3138            false,
3139            LangId::TypeScript
3140        ));
3141    }
3142
3143    // --- symbol_metadata ---
3144
3145    #[test]
3146    fn callgraph_symbol_metadata_populated() {
3147        let dir = setup_ts_project();
3148        let mut graph = CallGraph::new(dir.path().to_path_buf());
3149
3150        let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3151        assert!(
3152            file_data.symbol_metadata.contains_key("helper"),
3153            "symbol_metadata should contain helper"
3154        );
3155        let meta = &file_data.symbol_metadata["helper"];
3156        assert_eq!(meta.kind, SymbolKind::Function);
3157        assert!(meta.exported, "helper should be exported");
3158    }
3159
3160    // --- trace_to ---
3161
3162    /// Setup a multi-path project for trace_to tests.
3163    ///
3164    /// Structure:
3165    ///   main.ts: exported main() → processData (from utils)
3166    ///   service.ts: exported handleRequest() → processData (from utils)
3167    ///   utils.ts: exported processData() → validate (from helpers)
3168    ///   helpers.ts: exported validate() → checkFormat (local, not exported)
3169    ///   test_helpers.ts: testValidation() → validate (from helpers)
3170    ///
3171    /// checkFormat should have 3 paths:
3172    ///   main → processData → validate → checkFormat
3173    ///   handleRequest → processData → validate → checkFormat
3174    ///   testValidation → validate → checkFormat
3175    fn setup_trace_project() -> TempDir {
3176        let dir = TempDir::new().unwrap();
3177
3178        fs::write(
3179            dir.path().join("main.ts"),
3180            r#"import { processData } from './utils';
3181
3182export function main() {
3183    const result = processData("hello");
3184    return result;
3185}
3186"#,
3187        )
3188        .unwrap();
3189
3190        fs::write(
3191            dir.path().join("service.ts"),
3192            r#"import { processData } from './utils';
3193
3194export function handleRequest(input: string): string {
3195    return processData(input);
3196}
3197"#,
3198        )
3199        .unwrap();
3200
3201        fs::write(
3202            dir.path().join("utils.ts"),
3203            r#"import { validate } from './helpers';
3204
3205export function processData(input: string): string {
3206    const valid = validate(input);
3207    if (!valid) {
3208        throw new Error("invalid input");
3209    }
3210    return input.toUpperCase();
3211}
3212"#,
3213        )
3214        .unwrap();
3215
3216        fs::write(
3217            dir.path().join("helpers.ts"),
3218            r#"export function validate(input: string): boolean {
3219    return checkFormat(input);
3220}
3221
3222function checkFormat(input: string): boolean {
3223    return input.length > 0 && /^[a-zA-Z]+$/.test(input);
3224}
3225"#,
3226        )
3227        .unwrap();
3228
3229        fs::write(
3230            dir.path().join("test_helpers.ts"),
3231            r#"import { validate } from './helpers';
3232
3233function testValidation() {
3234    const result = validate("hello");
3235    console.log(result);
3236}
3237"#,
3238        )
3239        .unwrap();
3240
3241        // git init so the walker works
3242        std::process::Command::new("git")
3243            .args(["init"])
3244            .current_dir(dir.path())
3245            .output()
3246            .unwrap();
3247
3248        dir
3249    }
3250
3251    #[test]
3252    fn trace_to_multi_path() {
3253        let dir = setup_trace_project();
3254        let mut graph = CallGraph::new(dir.path().to_path_buf());
3255
3256        let result = graph
3257            .trace_to(
3258                &dir.path().join("helpers.ts"),
3259                "checkFormat",
3260                10,
3261                usize::MAX,
3262            )
3263            .unwrap();
3264
3265        assert_eq!(result.target_symbol, "checkFormat");
3266        assert!(
3267            result.total_paths >= 2,
3268            "checkFormat should have at least 2 paths, got {} (paths: {:?})",
3269            result.total_paths,
3270            result
3271                .paths
3272                .iter()
3273                .map(|p| p.hops.iter().map(|h| h.symbol.as_str()).collect::<Vec<_>>())
3274                .collect::<Vec<_>>()
3275        );
3276
3277        // Check that paths are top-down: entry point first, target last
3278        for path in &result.paths {
3279            assert!(
3280                path.hops.first().unwrap().is_entry_point,
3281                "First hop should be an entry point, got: {}",
3282                path.hops.first().unwrap().symbol
3283            );
3284            assert_eq!(
3285                path.hops.last().unwrap().symbol,
3286                "checkFormat",
3287                "Last hop should be checkFormat"
3288            );
3289        }
3290
3291        // Verify entry_points_found > 0
3292        assert!(
3293            result.entry_points_found >= 2,
3294            "should find at least 2 entry points, got {}",
3295            result.entry_points_found
3296        );
3297    }
3298
3299    #[test]
3300    fn trace_to_single_path() {
3301        let dir = setup_trace_project();
3302        let mut graph = CallGraph::new(dir.path().to_path_buf());
3303
3304        // validate is called from processData, testValidation
3305        // processData is called from main, handleRequest
3306        // So validate has paths: main→processData→validate, handleRequest→processData→validate, testValidation→validate
3307        let result = graph
3308            .trace_to(&dir.path().join("helpers.ts"), "validate", 10, usize::MAX)
3309            .unwrap();
3310
3311        assert_eq!(result.target_symbol, "validate");
3312        assert!(
3313            result.total_paths >= 2,
3314            "validate should have at least 2 paths, got {}",
3315            result.total_paths
3316        );
3317    }
3318
3319    #[test]
3320    fn trace_to_cycle_detection() {
3321        let dir = setup_cycle_project();
3322        let mut graph = CallGraph::new(dir.path().to_path_buf());
3323
3324        // funcA ↔ funcB cycle — should terminate
3325        let result = graph
3326            .trace_to(&dir.path().join("a.ts"), "funcA", 10, usize::MAX)
3327            .unwrap();
3328
3329        // Should not hang — the fact we got here means cycle detection works
3330        assert_eq!(result.target_symbol, "funcA");
3331    }
3332
3333    #[test]
3334    fn trace_to_depth_limit() {
3335        let dir = setup_trace_project();
3336        let mut graph = CallGraph::new(dir.path().to_path_buf());
3337
3338        // With max_depth=1, should not be able to reach entry points that are 3+ hops away
3339        let result = graph
3340            .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 1, usize::MAX)
3341            .unwrap();
3342
3343        // testValidation→validate→checkFormat is 2 hops, which requires depth >= 2
3344        // main→processData→validate→checkFormat is 3 hops, which requires depth >= 3
3345        // With depth=1, most paths should be truncated
3346        assert_eq!(result.target_symbol, "checkFormat");
3347
3348        // The shallow result should have fewer paths than the deep one
3349        let deep_result = graph
3350            .trace_to(
3351                &dir.path().join("helpers.ts"),
3352                "checkFormat",
3353                10,
3354                usize::MAX,
3355            )
3356            .unwrap();
3357
3358        assert!(
3359            result.total_paths <= deep_result.total_paths,
3360            "shallow trace should find <= paths compared to deep: {} vs {}",
3361            result.total_paths,
3362            deep_result.total_paths
3363        );
3364    }
3365
3366    #[test]
3367    fn trace_to_entry_point_target() {
3368        let dir = setup_trace_project();
3369        let mut graph = CallGraph::new(dir.path().to_path_buf());
3370
3371        // main is itself an entry point — should return a single trivial path
3372        let result = graph
3373            .trace_to(&dir.path().join("main.ts"), "main", 10, usize::MAX)
3374            .unwrap();
3375
3376        assert_eq!(result.target_symbol, "main");
3377        assert!(
3378            result.total_paths >= 1,
3379            "main should have at least 1 path (itself), got {}",
3380            result.total_paths
3381        );
3382        // Check the trivial path has just one hop
3383        let trivial = result.paths.iter().find(|p| p.hops.len() == 1);
3384        assert!(
3385            trivial.is_some(),
3386            "should have a trivial path with just the entry point itself"
3387        );
3388    }
3389
3390    // --- extract_parameters ---
3391
3392    #[test]
3393    fn extract_parameters_typescript() {
3394        let params = extract_parameters(
3395            "function processData(input: string, count: number): void",
3396            LangId::TypeScript,
3397        );
3398        assert_eq!(params, vec!["input", "count"]);
3399    }
3400
3401    #[test]
3402    fn extract_parameters_typescript_optional() {
3403        let params = extract_parameters(
3404            "function fetch(url: string, options?: RequestInit): Promise<Response>",
3405            LangId::TypeScript,
3406        );
3407        assert_eq!(params, vec!["url", "options"]);
3408    }
3409
3410    #[test]
3411    fn extract_parameters_typescript_defaults() {
3412        let params = extract_parameters(
3413            "function greet(name: string, greeting: string = \"hello\"): string",
3414            LangId::TypeScript,
3415        );
3416        assert_eq!(params, vec!["name", "greeting"]);
3417    }
3418
3419    #[test]
3420    fn extract_parameters_typescript_rest() {
3421        let params = extract_parameters(
3422            "function sum(...numbers: number[]): number",
3423            LangId::TypeScript,
3424        );
3425        assert_eq!(params, vec!["numbers"]);
3426    }
3427
3428    #[test]
3429    fn extract_parameters_python_self_skipped() {
3430        let params = extract_parameters(
3431            "def process(self, data: str, count: int) -> bool",
3432            LangId::Python,
3433        );
3434        assert_eq!(params, vec!["data", "count"]);
3435    }
3436
3437    #[test]
3438    fn extract_parameters_python_no_self() {
3439        let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
3440        assert_eq!(params, vec!["input"]);
3441    }
3442
3443    #[test]
3444    fn extract_parameters_python_star_args() {
3445        let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
3446        assert_eq!(params, vec!["args", "kwargs"]);
3447    }
3448
3449    #[test]
3450    fn extract_parameters_rust_self_skipped() {
3451        let params = extract_parameters(
3452            "fn process(&self, data: &str, count: usize) -> bool",
3453            LangId::Rust,
3454        );
3455        assert_eq!(params, vec!["data", "count"]);
3456    }
3457
3458    #[test]
3459    fn extract_parameters_rust_mut_self_skipped() {
3460        let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
3461        assert_eq!(params, vec!["value"]);
3462    }
3463
3464    #[test]
3465    fn extract_parameters_rust_no_self() {
3466        let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
3467        assert_eq!(params, vec!["input"]);
3468    }
3469
3470    #[test]
3471    fn extract_parameters_rust_mut_param() {
3472        let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
3473        assert_eq!(params, vec!["buf", "len"]);
3474    }
3475
3476    #[test]
3477    fn extract_parameters_go() {
3478        let params = extract_parameters(
3479            "func ProcessData(input string, count int) error",
3480            LangId::Go,
3481        );
3482        assert_eq!(params, vec!["input", "count"]);
3483    }
3484
3485    #[test]
3486    fn extract_parameters_empty() {
3487        let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
3488        assert!(
3489            params.is_empty(),
3490            "no-arg function should return empty params"
3491        );
3492    }
3493
3494    #[test]
3495    fn extract_parameters_no_parens() {
3496        let params = extract_parameters("const x = 42", LangId::TypeScript);
3497        assert!(params.is_empty(), "no parens should return empty params");
3498    }
3499
3500    #[test]
3501    fn extract_parameters_javascript() {
3502        let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
3503        assert_eq!(params, vec!["event", "target"]);
3504    }
3505}