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