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