Skip to main content

aft/
callgraph.rs

1//! Call graph engine: cross-file call resolution and forward traversal.
2//!
3//! Builds a lazy, worktree-scoped call graph that resolves calls across files
4//! using import chains. Supports depth-limited forward traversal with cycle
5//! detection.
6
7use std::cell::RefCell;
8use std::collections::{HashMap, HashSet};
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, LazyLock, RwLock};
11
12use globset::{Glob, GlobSet, GlobSetBuilder};
13use rayon::prelude::*;
14use serde::Serialize;
15use serde_json::Value;
16use tree_sitter::{Node, Parser};
17
18use crate::calls::{call_node_kinds, extract_callee_name, extract_calls_full, extract_full_callee};
19use crate::edit::line_col_to_byte;
20use crate::error::AftError;
21use crate::imports::{self, ImportBlock};
22use crate::language::LanguageProvider;
23use crate::parser::{detect_language, grammar_for, LangId};
24use crate::symbols::{Range, SymbolKind};
25
26// ---------------------------------------------------------------------------
27// Core types
28// ---------------------------------------------------------------------------
29
30type SharedPath = Arc<PathBuf>;
31type SharedStr = Arc<str>;
32type ReverseIndex = HashMap<PathBuf, HashMap<String, Vec<IndexedCallerSite>>>;
33type WorkspacePackageCache = HashMap<(PathBuf, String), Option<PathBuf>>;
34
35static WORKSPACE_PACKAGE_CACHE: LazyLock<RwLock<WorkspacePackageCache>> =
36    LazyLock::new(|| RwLock::new(HashMap::new()));
37
38const TOP_LEVEL_SYMBOL: &str = "<top-level>";
39
40/// A single call site within a function body.
41#[derive(Debug, Clone)]
42pub struct CallSite {
43    /// The short callee name (last segment, e.g. "foo" for `utils.foo()`).
44    pub callee_name: String,
45    /// The full callee expression (e.g. "utils.foo" for `utils.foo()`).
46    pub full_callee: String,
47    /// 1-based line number of the call.
48    pub line: u32,
49    /// Byte range of the call expression in the source.
50    pub byte_start: usize,
51    pub byte_end: usize,
52}
53
54/// Per-symbol metadata for entry point detection (avoids re-parsing).
55#[derive(Debug, Clone, Serialize)]
56pub struct SymbolMeta {
57    /// The kind of symbol (function, class, method, etc).
58    pub kind: SymbolKind,
59    /// Whether this symbol is exported.
60    pub exported: bool,
61    /// Function/method signature if available.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub signature: Option<String>,
64    /// 1-based start line of the symbol.
65    pub line: u32,
66    /// 0-based source range of the symbol.
67    pub range: Range,
68}
69
70/// Per-file call data: call sites grouped by containing symbol, plus
71/// exported symbol names and parsed imports.
72#[derive(Debug, Clone)]
73pub struct FileCallData {
74    /// Map from symbol name → list of call sites within that symbol's body.
75    pub calls_by_symbol: HashMap<String, Vec<CallSite>>,
76    /// Names of exported symbols in this file.
77    pub exported_symbols: Vec<String>,
78    /// Per-symbol metadata (kind, exported, signature).
79    pub symbol_metadata: HashMap<String, SymbolMeta>,
80    /// Real or synthetic symbol name for this file's default export.
81    pub default_export_symbol: Option<String>,
82    /// Parsed import block for cross-file resolution.
83    pub import_block: ImportBlock,
84    /// Language of the file.
85    pub lang: LangId,
86}
87
88/// Result of resolving a cross-file call edge.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum EdgeResolution {
91    /// Successfully resolved to a specific file and symbol.
92    Resolved { file: PathBuf, symbol: String },
93    /// Could not resolve — callee name preserved for diagnostics.
94    Unresolved { callee_name: String },
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
98struct ResolvedSymbol {
99    file: PathBuf,
100    symbol: String,
101}
102
103/// A single caller site: who calls a given symbol and from where.
104#[derive(Debug, Clone, Serialize)]
105pub struct CallerSite {
106    /// File containing the caller.
107    pub caller_file: PathBuf,
108    /// Symbol that makes the call.
109    pub caller_symbol: String,
110    /// 1-based line number of the call.
111    pub line: u32,
112    /// 0-based column (byte start within file, kept for future use).
113    pub col: u32,
114    /// Whether the edge was resolved via import chain.
115    pub resolved: bool,
116}
117
118#[derive(Debug, Clone)]
119struct IndexedCallerSite {
120    caller_file: SharedPath,
121    caller_symbol: SharedStr,
122    line: u32,
123    col: u32,
124    resolved: bool,
125}
126
127/// A group of callers from a single file.
128#[derive(Debug, Clone, Serialize)]
129pub struct CallerGroup {
130    /// File path (relative to project root).
131    pub file: String,
132    /// Individual call sites in this file.
133    pub callers: Vec<CallerEntry>,
134}
135
136/// A single caller entry within a CallerGroup.
137#[derive(Debug, Clone, Serialize)]
138pub struct CallerEntry {
139    pub symbol: String,
140    /// 1-based line number of the call.
141    pub line: u32,
142}
143
144/// Result of a `callers_of` query.
145#[derive(Debug, Clone, Serialize)]
146pub struct CallersResult {
147    /// Target symbol queried.
148    pub symbol: String,
149    /// Target file queried.
150    pub file: String,
151    /// Caller groups, one per calling file.
152    pub callers: Vec<CallerGroup>,
153    /// Total number of call sites found.
154    pub total_callers: usize,
155    /// Number of files scanned to build the reverse index.
156    pub scanned_files: usize,
157}
158
159/// A node in the forward call tree.
160#[derive(Debug, Clone, Serialize)]
161pub struct CallTreeNode {
162    /// Symbol name.
163    pub name: String,
164    /// File path (relative to project root when possible).
165    pub file: String,
166    /// 1-based line number.
167    pub line: u32,
168    /// Function signature if available.
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub signature: Option<String>,
171    /// Whether this edge was resolved cross-file.
172    pub resolved: bool,
173    /// Child calls (recursive).
174    pub children: Vec<CallTreeNode>,
175}
176
177// ---------------------------------------------------------------------------
178// Entry point detection
179// ---------------------------------------------------------------------------
180
181/// Well-known main/init function names (case-insensitive exact match).
182const MAIN_INIT_NAMES: &[&str] = &["main", "init", "setup", "bootstrap", "run"];
183
184/// Determine whether a symbol is an entry point.
185///
186/// Entry points are:
187/// - Exported standalone functions (not methods — methods are class members)
188/// - Functions matching well-known main/init patterns (any language)
189/// - Test functions matching language-specific patterns
190pub fn is_entry_point(name: &str, kind: &SymbolKind, exported: bool, lang: LangId) -> bool {
191    // Exported standalone functions
192    if exported && *kind == SymbolKind::Function {
193        return true;
194    }
195
196    // Main/init patterns (case-insensitive exact match, any kind)
197    let lower = name.to_lowercase();
198    if MAIN_INIT_NAMES.contains(&lower.as_str()) {
199        return true;
200    }
201
202    // Test patterns by language
203    match lang {
204        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
205            // describe, it, test (exact), or starts with test/spec
206            matches!(lower.as_str(), "describe" | "it" | "test")
207                || lower.starts_with("test")
208                || lower.starts_with("spec")
209        }
210        LangId::Python => {
211            // starts with test_ or matches setUp/tearDown
212            lower.starts_with("test_") || matches!(name, "setUp" | "tearDown")
213        }
214        LangId::Rust => {
215            // starts with test_
216            lower.starts_with("test_")
217        }
218        LangId::Go => {
219            // starts with Test (case-sensitive)
220            name.starts_with("Test")
221        }
222        LangId::C
223        | LangId::Cpp
224        | LangId::Zig
225        | LangId::CSharp
226        | LangId::Bash
227        | LangId::Solidity
228        | LangId::Vue
229        | LangId::Json
230        | LangId::Scala
231        | LangId::Html
232        | LangId::Markdown => false,
233    }
234}
235
236// ---------------------------------------------------------------------------
237// Trace-to types
238// ---------------------------------------------------------------------------
239
240/// A single hop in a trace path.
241#[derive(Debug, Clone, Serialize)]
242pub struct TraceHop {
243    /// Symbol name at this hop.
244    pub symbol: String,
245    /// File path (relative to project root).
246    pub file: String,
247    /// 1-based line number.
248    pub line: u32,
249    /// Function signature if available.
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub signature: Option<String>,
252    /// Whether this hop is an entry point.
253    pub is_entry_point: bool,
254}
255
256/// A complete path from an entry point to the target symbol (top-down).
257#[derive(Debug, Clone, Serialize)]
258pub struct TracePath {
259    /// Hops from entry point (first) to target (last).
260    pub hops: Vec<TraceHop>,
261}
262
263/// Result of a `trace_to` query.
264#[derive(Debug, Clone, Serialize)]
265pub struct TraceToResult {
266    /// The target symbol that was traced.
267    pub target_symbol: String,
268    /// The target file (relative to project root).
269    pub target_file: String,
270    /// Complete paths from entry points to the target.
271    pub paths: Vec<TracePath>,
272    /// Total number of complete paths found.
273    pub total_paths: usize,
274    /// Number of distinct entry points found across all paths.
275    pub entry_points_found: usize,
276    /// Whether any path was cut short by the depth limit.
277    pub max_depth_reached: bool,
278    /// Number of paths that reached a dead end (no callers, not entry point).
279    pub truncated_paths: usize,
280}
281
282// ---------------------------------------------------------------------------
283// Impact analysis types
284// ---------------------------------------------------------------------------
285
286/// A single caller in an impact analysis result.
287#[derive(Debug, Clone, Serialize)]
288pub struct ImpactCaller {
289    /// Symbol that calls the target.
290    pub caller_symbol: String,
291    /// File containing the caller (relative to project root).
292    pub caller_file: String,
293    /// 1-based line number of the call site.
294    pub line: u32,
295    /// Caller's function/method signature, if available.
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub signature: Option<String>,
298    /// Whether the caller is an entry point.
299    pub is_entry_point: bool,
300    /// Source line at the call site (trimmed).
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub call_expression: Option<String>,
303    /// Parameter names extracted from the caller's signature.
304    pub parameters: Vec<String>,
305}
306
307/// Result of an `impact` query — enriched callers analysis.
308#[derive(Debug, Clone, Serialize)]
309pub struct ImpactResult {
310    /// The target symbol being analyzed.
311    pub symbol: String,
312    /// The target file (relative to project root).
313    pub file: String,
314    /// Target symbol's signature, if available.
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub signature: Option<String>,
317    /// Parameter names extracted from the target's signature.
318    pub parameters: Vec<String>,
319    /// Total number of affected call sites.
320    pub total_affected: usize,
321    /// Number of distinct files containing callers.
322    pub affected_files: usize,
323    /// Enriched caller details.
324    pub callers: Vec<ImpactCaller>,
325}
326
327// ---------------------------------------------------------------------------
328// Data flow tracking types
329// ---------------------------------------------------------------------------
330
331/// A single hop in a data flow trace.
332#[derive(Debug, Clone, Serialize)]
333pub struct DataFlowHop {
334    /// File path (relative to project root).
335    pub file: String,
336    /// Symbol (function/method) containing this hop.
337    pub symbol: String,
338    /// Variable or parameter name being tracked at this hop.
339    pub variable: String,
340    /// 1-based line number.
341    pub line: u32,
342    /// Type of data flow: "assignment", "parameter", or "return".
343    pub flow_type: String,
344    /// Whether this hop is an approximation (destructuring, spread, unresolved).
345    pub approximate: bool,
346}
347
348/// Result of a `trace_data` query — tracks how an expression flows through
349/// variable assignments and function parameters.
350#[derive(Debug, Clone, Serialize)]
351pub struct TraceDataResult {
352    /// The expression being tracked.
353    pub expression: String,
354    /// The file where tracking started.
355    pub origin_file: String,
356    /// The symbol where tracking started.
357    pub origin_symbol: String,
358    /// Hops through assignments and parameters.
359    pub hops: Vec<DataFlowHop>,
360    /// Whether tracking stopped due to depth limit.
361    pub depth_limited: bool,
362}
363
364/// Extract parameter names from a function signature string.
365///
366/// Strips language-specific receivers (`self`, `&self`, `&mut self` for Rust,
367/// `self` for Python) and type annotations / default values. Returns just
368/// the parameter names.
369pub fn extract_parameters(signature: &str, lang: LangId) -> Vec<String> {
370    // Find the parameter list between parentheses
371    let start = match signature.find('(') {
372        Some(i) => i + 1,
373        None => return Vec::new(),
374    };
375    let end = match signature[start..].find(')') {
376        Some(i) => start + i,
377        None => return Vec::new(),
378    };
379
380    let params_str = &signature[start..end].trim();
381    if params_str.is_empty() {
382        return Vec::new();
383    }
384
385    // Split on commas, respecting nested generics/brackets
386    let parts = split_params(params_str);
387
388    let mut result = Vec::new();
389    for part in parts {
390        let trimmed = part.trim();
391        if trimmed.is_empty() {
392            continue;
393        }
394
395        // Skip language-specific receivers
396        match lang {
397            LangId::Rust => {
398                if trimmed == "self"
399                    || trimmed == "mut self"
400                    || trimmed.starts_with("&self")
401                    || trimmed.starts_with("&mut self")
402                {
403                    continue;
404                }
405            }
406            LangId::Python => {
407                if trimmed == "self" || trimmed.starts_with("self:") {
408                    continue;
409                }
410            }
411            _ => {}
412        }
413
414        // Extract just the parameter name
415        let name = extract_param_name(trimmed, lang);
416        if !name.is_empty() {
417            result.push(name);
418        }
419    }
420
421    result
422}
423
424/// Split parameter string on commas, respecting nested brackets/generics.
425fn split_params(s: &str) -> Vec<String> {
426    let mut parts = Vec::new();
427    let mut current = String::new();
428    let mut depth = 0i32;
429
430    for ch in s.chars() {
431        match ch {
432            '<' | '[' | '{' | '(' => {
433                depth += 1;
434                current.push(ch);
435            }
436            '>' | ']' | '}' | ')' => {
437                depth -= 1;
438                current.push(ch);
439            }
440            ',' if depth == 0 => {
441                parts.push(current.clone());
442                current.clear();
443            }
444            _ => {
445                current.push(ch);
446            }
447        }
448    }
449    if !current.is_empty() {
450        parts.push(current);
451    }
452    parts
453}
454
455/// Extract the parameter name from a single parameter declaration.
456///
457/// Handles:
458/// - TS/JS: `name: Type`, `name = default`, `...name`, `name?: Type`
459/// - Python: `name: Type`, `name=default`, `*args`, `**kwargs`
460/// - Rust: `name: Type`, `mut name: Type`
461/// - Go: `name Type`, `name, name2 Type`
462fn extract_param_name(param: &str, lang: LangId) -> String {
463    let trimmed = param.trim();
464
465    // Handle rest/spread params
466    let working = if trimmed.starts_with("...") {
467        &trimmed[3..]
468    } else if trimmed.starts_with("**") {
469        &trimmed[2..]
470    } else if trimmed.starts_with('*') && lang == LangId::Python {
471        &trimmed[1..]
472    } else {
473        trimmed
474    };
475
476    // Rust: `mut name: Type` → strip `mut `
477    let working = if lang == LangId::Rust && working.starts_with("mut ") {
478        &working[4..]
479    } else {
480        working
481    };
482
483    // Strip type annotation (`: Type`) and default values (`= default`)
484    // Take only the name part — everything before `:`, `=`, or `?`
485    let name = working
486        .split(|c: char| c == ':' || c == '=')
487        .next()
488        .unwrap_or("")
489        .trim();
490
491    // Strip trailing `?` (optional params in TS)
492    let name = name.trim_end_matches('?');
493
494    // For Go, the name might be just `name Type` — take the first word
495    if lang == LangId::Go && !name.contains(' ') {
496        return name.to_string();
497    }
498    if lang == LangId::Go {
499        return name.split_whitespace().next().unwrap_or("").to_string();
500    }
501
502    name.to_string()
503}
504
505// ---------------------------------------------------------------------------
506// CallGraph
507// ---------------------------------------------------------------------------
508
509/// Worktree-scoped call graph with lazy per-file construction.
510///
511/// Files are parsed and analyzed on first access, then cached. The graph
512/// can resolve cross-file call edges using the import engine.
513pub struct CallGraph {
514    /// Cached per-file call data.
515    data: HashMap<PathBuf, FileCallData>,
516    /// Project root for relative path resolution.
517    project_root: PathBuf,
518    /// All files discovered in the worktree (lazily populated).
519    project_files: Option<Vec<PathBuf>>,
520    /// Reverse index: target_file → target_symbol → callers.
521    /// Built lazily on first `callers_of` call, cleared on `invalidate_file`.
522    reverse_index: Option<ReverseIndex>,
523}
524
525impl CallGraph {
526    /// Create a new call graph for a project.
527    pub fn new(project_root: PathBuf) -> Self {
528        clear_workspace_package_cache();
529        Self {
530            data: HashMap::new(),
531            project_root,
532            project_files: None,
533            reverse_index: None,
534        }
535    }
536
537    /// Get the project root directory.
538    pub fn project_root(&self) -> &Path {
539        &self.project_root
540    }
541
542    fn resolve_cross_file_edge_with_exports<F, D>(
543        full_callee: &str,
544        short_name: &str,
545        caller_file: &Path,
546        import_block: &ImportBlock,
547        mut file_exports_symbol: F,
548        mut file_default_export_symbol: D,
549    ) -> EdgeResolution
550    where
551        F: FnMut(&Path, &str) -> bool,
552        D: FnMut(&Path) -> Option<String>,
553    {
554        let caller_dir = caller_file.parent().unwrap_or(Path::new("."));
555
556        // Check namespace imports: "utils.foo" where utils is a namespace import
557        if full_callee.contains('.') {
558            let parts: Vec<&str> = full_callee.splitn(2, '.').collect();
559            if parts.len() == 2 {
560                let namespace = parts[0];
561                let member = parts[1];
562
563                for imp in &import_block.imports {
564                    if imp.namespace_import.as_deref() == Some(namespace) {
565                        if let Some(resolved_path) =
566                            resolve_module_path(caller_dir, &imp.module_path)
567                        {
568                            return EdgeResolution::Resolved {
569                                file: resolved_path,
570                                symbol: member.to_owned(),
571                            };
572                        }
573                    }
574                }
575            }
576        }
577
578        // Check named imports (direct and aliased)
579        for imp in &import_block.imports {
580            // Direct named import: import { foo } from './utils'
581            if imp.names.iter().any(|name| name == short_name) {
582                if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
583                    let target = resolve_reexported_symbol(
584                        &resolved_path,
585                        short_name,
586                        &mut file_exports_symbol,
587                        &mut file_default_export_symbol,
588                    )
589                    .unwrap_or(ResolvedSymbol {
590                        file: resolved_path,
591                        symbol: short_name.to_owned(),
592                    });
593                    return EdgeResolution::Resolved {
594                        file: target.file,
595                        symbol: target.symbol,
596                    };
597                }
598            }
599
600            // Default import: import foo from './utils'
601            if imp.default_import.as_deref() == Some(short_name) {
602                if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
603                    let target = resolve_reexported_symbol(
604                        &resolved_path,
605                        "default",
606                        &mut file_exports_symbol,
607                        &mut file_default_export_symbol,
608                    )
609                    .unwrap_or_else(|| ResolvedSymbol {
610                        symbol: file_default_export_symbol(&resolved_path)
611                            .unwrap_or_else(|| synthetic_default_symbol(&resolved_path)),
612                        file: resolved_path,
613                    });
614                    return EdgeResolution::Resolved {
615                        file: target.file,
616                        symbol: target.symbol,
617                    };
618                }
619            }
620        }
621
622        // Check aliased imports by examining the raw import text.
623        // ImportStatement.names stores the original name (foo), but the local code
624        // uses the alias (bar). We need to parse `import { foo as bar }` to find
625        // that `bar` maps to `foo`.
626        if let Some((original_name, resolved_path)) =
627            resolve_aliased_import(short_name, import_block, caller_dir)
628        {
629            let target = resolve_reexported_symbol(
630                &resolved_path,
631                &original_name,
632                &mut file_exports_symbol,
633                &mut file_default_export_symbol,
634            )
635            .unwrap_or(ResolvedSymbol {
636                file: resolved_path,
637                symbol: original_name,
638            });
639            return EdgeResolution::Resolved {
640                file: target.file,
641                symbol: target.symbol,
642            };
643        }
644
645        // Try barrel file re-exports: if any import points to an index file,
646        // check if that file re-exports the symbol
647        for imp in &import_block.imports {
648            if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
649                // Check if the resolved path is a directory (barrel file)
650                if resolved_path.is_dir() {
651                    if let Some(index_path) = find_index_file(&resolved_path) {
652                        // Check if the index file exports this symbol
653                        if file_exports_symbol(&index_path, short_name) {
654                            return EdgeResolution::Resolved {
655                                file: index_path,
656                                symbol: short_name.to_owned(),
657                            };
658                        }
659                    }
660                } else if file_exports_symbol(&resolved_path, short_name) {
661                    return EdgeResolution::Resolved {
662                        file: resolved_path,
663                        symbol: short_name.to_owned(),
664                    };
665                }
666            }
667        }
668
669        EdgeResolution::Unresolved {
670            callee_name: short_name.to_owned(),
671        }
672    }
673
674    /// Get or build the call data for a file.
675    pub fn build_file(&mut self, path: &Path) -> Result<&FileCallData, AftError> {
676        let canon = self.canonicalize(path)?;
677
678        if !self.data.contains_key(&canon) {
679            let file_data = build_file_data(&canon)?;
680            self.data.insert(canon.clone(), file_data);
681        }
682
683        Ok(&self.data[&canon])
684    }
685
686    /// Resolve a cross-file call edge.
687    ///
688    /// Given a callee expression and the calling file's import block,
689    /// determines which file and symbol the call targets.
690    pub fn resolve_cross_file_edge(
691        &mut self,
692        full_callee: &str,
693        short_name: &str,
694        caller_file: &Path,
695        import_block: &ImportBlock,
696    ) -> EdgeResolution {
697        let graph = RefCell::new(self);
698        Self::resolve_cross_file_edge_with_exports(
699            full_callee,
700            short_name,
701            caller_file,
702            import_block,
703            |path, symbol_name| graph.borrow_mut().file_exports_symbol(path, symbol_name),
704            |path| graph.borrow_mut().file_default_export_symbol(path),
705        )
706    }
707
708    /// Check if a file exports a given symbol name.
709    fn file_exports_symbol(&mut self, path: &Path, symbol_name: &str) -> bool {
710        match self.build_file(path) {
711            Ok(data) => data.exported_symbols.iter().any(|name| name == symbol_name),
712            Err(_) => false,
713        }
714    }
715
716    fn file_default_export_symbol(&mut self, path: &Path) -> Option<String> {
717        self.build_file(path)
718            .ok()
719            .and_then(|data| data.default_export_symbol.clone())
720    }
721
722    fn file_exports_symbol_cached(&self, path: &Path, symbol_name: &str) -> bool {
723        self.lookup_file_data(path)
724            .map(|data| data.exported_symbols.iter().any(|name| name == symbol_name))
725            .unwrap_or(false)
726    }
727
728    fn file_default_export_symbol_cached(&self, path: &Path) -> Option<String> {
729        self.lookup_file_data(path)
730            .and_then(|data| data.default_export_symbol.clone())
731    }
732
733    /// Depth-limited forward call tree traversal.
734    ///
735    /// Starting from a (file, symbol) pair, recursively follows calls
736    /// up to `max_depth` levels. Uses a visited set for cycle detection.
737    pub fn forward_tree(
738        &mut self,
739        file: &Path,
740        symbol: &str,
741        max_depth: usize,
742    ) -> Result<CallTreeNode, AftError> {
743        let mut visited = HashSet::new();
744        self.forward_tree_inner(file, symbol, max_depth, 0, &mut visited)
745    }
746
747    fn forward_tree_inner(
748        &mut self,
749        file: &Path,
750        symbol: &str,
751        max_depth: usize,
752        current_depth: usize,
753        visited: &mut HashSet<(PathBuf, String)>,
754    ) -> Result<CallTreeNode, AftError> {
755        let canon = self.canonicalize(file)?;
756        let visit_key = (canon.clone(), symbol.to_string());
757
758        // Cycle detection
759        if visited.contains(&visit_key) {
760            let (line, signature) = self
761                .lookup_file_data(&canon)
762                .map(|data| get_symbol_meta_from_data(data, symbol))
763                .unwrap_or_else(|| get_symbol_meta(&canon, symbol));
764            return Ok(CallTreeNode {
765                name: symbol.to_string(),
766                file: self.relative_path(&canon),
767                line,
768                signature,
769                resolved: true,
770                children: vec![], // cycle — stop recursion
771            });
772        }
773
774        visited.insert(visit_key.clone());
775
776        let (import_block, call_sites, sym_line, sym_signature) = {
777            let file_data = self.build_file(&canon)?;
778            let meta = get_symbol_meta_from_data(file_data, symbol);
779
780            (
781                file_data.import_block.clone(),
782                file_data
783                    .calls_by_symbol
784                    .get(symbol)
785                    .cloned()
786                    .unwrap_or_default(),
787                meta.0,
788                meta.1,
789            )
790        };
791
792        // Build children
793        let mut children = Vec::new();
794
795        if current_depth < max_depth {
796            for call_site in &call_sites {
797                let edge = self.resolve_cross_file_edge(
798                    &call_site.full_callee,
799                    &call_site.callee_name,
800                    &canon,
801                    &import_block,
802                );
803
804                match edge {
805                    EdgeResolution::Resolved {
806                        file: ref target_file,
807                        ref symbol,
808                    } => {
809                        match self.forward_tree_inner(
810                            target_file,
811                            symbol,
812                            max_depth,
813                            current_depth + 1,
814                            visited,
815                        ) {
816                            Ok(child) => children.push(child),
817                            Err(_) => {
818                                // Target file can't be parsed — mark as unresolved leaf
819                                children.push(CallTreeNode {
820                                    name: call_site.callee_name.clone(),
821                                    file: self.relative_path(target_file),
822                                    line: call_site.line,
823                                    signature: None,
824                                    resolved: false,
825                                    children: vec![],
826                                });
827                            }
828                        }
829                    }
830                    EdgeResolution::Unresolved { callee_name } => {
831                        if let Some(local_child) = self.resolve_local_call_tree_child(
832                            &canon,
833                            symbol,
834                            call_site,
835                            &callee_name,
836                            max_depth,
837                            current_depth,
838                            visited,
839                        )? {
840                            children.push(local_child);
841                            continue;
842                        }
843                        children.push(CallTreeNode {
844                            name: callee_name,
845                            file: self.relative_path(&canon),
846                            line: call_site.line,
847                            signature: None,
848                            resolved: false,
849                            children: vec![],
850                        });
851                    }
852                }
853            }
854        }
855
856        visited.remove(&visit_key);
857
858        Ok(CallTreeNode {
859            name: symbol.to_string(),
860            file: self.relative_path(&canon),
861            line: sym_line,
862            signature: sym_signature,
863            resolved: true,
864            children,
865        })
866    }
867
868    fn resolve_local_call_tree_child(
869        &mut self,
870        canon: &Path,
871        current_symbol: &str,
872        call_site: &CallSite,
873        callee_name: &str,
874        max_depth: usize,
875        current_depth: usize,
876        visited: &mut HashSet<(PathBuf, String)>,
877    ) -> Result<Option<CallTreeNode>, AftError> {
878        let has_local_symbol = self
879            .lookup_file_data(canon)
880            .map(|data| data.symbol_metadata.contains_key(callee_name))
881            .unwrap_or(false);
882        if !has_local_symbol {
883            return Ok(None);
884        }
885        if callee_name == current_symbol {
886            return Ok(None);
887        }
888
889        match self.forward_tree_inner(canon, callee_name, max_depth, current_depth + 1, visited) {
890            Ok(child) => Ok(Some(child)),
891            Err(_) => Ok(Some(CallTreeNode {
892                name: callee_name.to_string(),
893                file: self.relative_path(canon),
894                line: call_site.line,
895                signature: None,
896                resolved: false,
897                children: vec![],
898            })),
899        }
900    }
901
902    /// Get all project files (lazily discovered).
903    pub fn project_files(&mut self) -> &[PathBuf] {
904        if self.project_files.is_none() {
905            let project_root = self.project_root.clone();
906            self.project_files = Some(walk_project_files(&project_root).collect());
907        }
908        self.project_files.as_deref().unwrap_or(&[])
909    }
910
911    /// Get the total number of project source files.
912    ///
913    /// Triggers project file discovery on first access and returns the cached
914    /// count thereafter. Prefer [`project_file_count_bounded`] when the caller
915    /// only needs to know whether a threshold is exceeded.
916    pub fn project_file_count(&mut self) -> usize {
917        self.project_files().len()
918    }
919
920    /// Count project source files, stopping after `limit + 1` so huge roots
921    /// do not pay for a full walk or allocate a giant vector.
922    ///
923    /// Returns the real count when ≤ `limit`, or `limit + 1` when exceeded.
924    /// Uses the cached `project_files` vec when it already exists (e.g. a
925    /// previous call-graph op succeeded at this cap), otherwise short-circuits
926    /// the underlying `ignore::Walk` iterator via `.take(limit + 1)`.
927    ///
928    /// CRITICAL: This method must NOT populate `self.project_files`. The whole
929    /// point is to reject oversized roots before the full walk-and-collect runs.
930    pub fn project_file_count_bounded(&self, limit: usize) -> usize {
931        if let Some(files) = self.project_files.as_deref() {
932            return files.len();
933        }
934        walk_project_files(&self.project_root)
935            .take(limit.saturating_add(1))
936            .count()
937    }
938
939    /// Build the reverse index by scanning all project files.
940    ///
941    /// For each file, builds the call data (if not cached), then for each
942    /// (symbol, call_sites) pair, resolves cross-file edges and inserts
943    /// into the reverse map: `(target_file, target_symbol) → Vec<CallerSite>`.
944    fn build_reverse_index(&mut self, max_files: usize) -> Result<(), AftError> {
945        // Bounded count first — never populate project_files on oversized roots.
946        // `walk_project_files(...).take(max_files + 1)` is lazy (Walk is an
947        // iterator), so this costs at most (max_files + 1) directory entries
948        // worth of work, not a full O(N) walk of the whole tree.
949        let count = self.project_file_count_bounded(max_files);
950        if count > max_files {
951            return Err(AftError::ProjectTooLarge {
952                count,
953                max: max_files,
954            });
955        }
956
957        // TODO(v0.16): rust-side deadline for graceful timeout recovery
958        // (unbounded walks remain a soft cliff for users who raise the cap).
959        // Discover all project files first
960        let all_files = self.project_files().to_vec();
961
962        // Build file data for all project files
963        let uncached_files: Vec<PathBuf> = all_files
964            .iter()
965            .filter(|f| self.lookup_file_data(f).is_none())
966            .cloned()
967            .collect();
968
969        let computed: Vec<(PathBuf, FileCallData)> = uncached_files
970            .par_iter()
971            .filter_map(|f| build_file_data(f).ok().map(|data| (f.clone(), data)))
972            .collect();
973
974        for (file, data) in computed {
975            self.data.insert(file, data);
976        }
977
978        // Now build the reverse map
979        let mut reverse: ReverseIndex = HashMap::new();
980
981        for caller_file in &all_files {
982            // Canonicalize the caller file path for consistent lookups
983            let canon_caller = Arc::new(
984                std::fs::canonicalize(caller_file).unwrap_or_else(|_| caller_file.clone()),
985            );
986            let file_data = match self
987                .data
988                .get(caller_file)
989                .or_else(|| self.data.get(canon_caller.as_ref()))
990            {
991                Some(d) => d,
992                None => continue,
993            };
994
995            for (symbol_name, call_sites) in &file_data.calls_by_symbol {
996                let caller_symbol: SharedStr = Arc::from(symbol_name.as_str());
997
998                for call_site in call_sites {
999                    let edge = Self::resolve_cross_file_edge_with_exports(
1000                        &call_site.full_callee,
1001                        &call_site.callee_name,
1002                        canon_caller.as_ref(),
1003                        &file_data.import_block,
1004                        |path, symbol_name| self.file_exports_symbol_cached(path, symbol_name),
1005                        |path| self.file_default_export_symbol_cached(path),
1006                    );
1007
1008                    let (target_file, target_symbol, resolved) = match edge {
1009                        EdgeResolution::Resolved { file, symbol } => (file, symbol, true),
1010                        EdgeResolution::Unresolved { callee_name } => {
1011                            (canon_caller.as_ref().clone(), callee_name, false)
1012                        }
1013                    };
1014
1015                    if target_file == *canon_caller.as_ref() && target_symbol == *symbol_name {
1016                        continue;
1017                    }
1018
1019                    reverse
1020                        .entry(target_file)
1021                        .or_default()
1022                        .entry(target_symbol)
1023                        .or_default()
1024                        .push(IndexedCallerSite {
1025                            caller_file: Arc::clone(&canon_caller),
1026                            caller_symbol: Arc::clone(&caller_symbol),
1027                            line: call_site.line,
1028                            col: 0,
1029                            resolved,
1030                        });
1031                }
1032            }
1033        }
1034
1035        self.reverse_index = Some(reverse);
1036        Ok(())
1037    }
1038
1039    fn reverse_sites(&self, file: &Path, symbol: &str) -> Option<&[IndexedCallerSite]> {
1040        self.reverse_index
1041            .as_ref()?
1042            .get(file)?
1043            .get(symbol)
1044            .map(Vec::as_slice)
1045    }
1046
1047    /// Get callers of a symbol in a file, grouped by calling file.
1048    ///
1049    /// Builds the reverse index on first call (scans all project files).
1050    /// Supports recursive depth expansion: depth=1 returns direct callers,
1051    /// depth=2 returns callers-of-callers, etc. depth=0 is treated as 1.
1052    pub fn callers_of(
1053        &mut self,
1054        file: &Path,
1055        symbol: &str,
1056        depth: usize,
1057        max_files: usize,
1058    ) -> Result<CallersResult, AftError> {
1059        let canon = self.canonicalize(file)?;
1060
1061        // Ensure file is built (may already be cached)
1062        self.build_file(&canon)?;
1063
1064        // Build the reverse index if not cached
1065        if self.reverse_index.is_none() {
1066            self.build_reverse_index(max_files)?;
1067        }
1068
1069        let scanned_files = self.project_files.as_ref().map(|f| f.len()).unwrap_or(0);
1070        let effective_depth = if depth == 0 { 1 } else { depth };
1071
1072        let mut visited = HashSet::new();
1073        let mut all_sites: Vec<CallerSite> = Vec::new();
1074        self.collect_callers_recursive(
1075            &canon,
1076            symbol,
1077            effective_depth,
1078            0,
1079            &mut visited,
1080            &mut all_sites,
1081        );
1082
1083        // Group by file
1084
1085        let mut groups_map: HashMap<PathBuf, Vec<CallerEntry>> = HashMap::new();
1086        let total_callers = all_sites.len();
1087        for site in all_sites {
1088            let caller_file: PathBuf = site.caller_file;
1089            let caller_symbol: String = site.caller_symbol;
1090            let line = site.line;
1091            let entry = CallerEntry {
1092                symbol: caller_symbol,
1093                line,
1094            };
1095
1096            if let Some(entries) = groups_map.get_mut(&caller_file) {
1097                entries.push(entry);
1098            } else {
1099                groups_map.insert(caller_file, vec![entry]);
1100            }
1101        }
1102
1103        let mut callers: Vec<CallerGroup> = groups_map
1104            .into_iter()
1105            .map(|(file_path, entries)| CallerGroup {
1106                file: self.relative_path(&file_path),
1107                callers: entries,
1108            })
1109            .collect();
1110
1111        // Sort groups by file path for deterministic output
1112        callers.sort_by(|a, b| a.file.cmp(&b.file));
1113
1114        Ok(CallersResult {
1115            symbol: symbol.to_string(),
1116            file: self.relative_path(&canon),
1117            callers,
1118            total_callers,
1119            scanned_files,
1120        })
1121    }
1122
1123    /// Trace backward from a symbol to all entry points.
1124    ///
1125    /// Returns complete paths (top-down: entry point first, target last).
1126    /// Uses BFS backward through the reverse index, with per-path cycle
1127    /// detection and depth limiting.
1128    pub fn trace_to(
1129        &mut self,
1130        file: &Path,
1131        symbol: &str,
1132        max_depth: usize,
1133        max_files: usize,
1134    ) -> Result<TraceToResult, AftError> {
1135        let canon = self.canonicalize(file)?;
1136
1137        // Ensure file is built
1138        self.build_file(&canon)?;
1139
1140        // Build the reverse index if not cached
1141        if self.reverse_index.is_none() {
1142            self.build_reverse_index(max_files)?;
1143        }
1144
1145        let target_rel = self.relative_path(&canon);
1146        let effective_max = if max_depth == 0 { 10 } else { max_depth };
1147        if self.reverse_index.is_none() {
1148            return Err(AftError::ParseError {
1149                message: format!(
1150                    "reverse index unavailable after building callers for {}",
1151                    canon.display()
1152                ),
1153            });
1154        }
1155
1156        // Get line/signature for the target symbol
1157        let (target_line, target_sig) = self
1158            .lookup_file_data(&canon)
1159            .map(|data| get_symbol_meta_from_data(data, symbol))
1160            .unwrap_or_else(|| get_symbol_meta(&canon, symbol));
1161
1162        // Check if target itself is an entry point
1163        let target_is_entry = self
1164            .lookup_file_data(&canon)
1165            .and_then(|fd| {
1166                let meta = fd.symbol_metadata.get(symbol)?;
1167                Some(is_entry_point(symbol, &meta.kind, meta.exported, fd.lang))
1168            })
1169            .unwrap_or(false);
1170
1171        // BFS state: each item is a partial path (bottom-up, will be reversed later)
1172        // Each path element: (canonicalized file, symbol name, line, signature)
1173        type PathElem = (SharedPath, SharedStr, u32, Option<String>);
1174        let mut complete_paths: Vec<Vec<PathElem>> = Vec::new();
1175        let mut max_depth_reached = false;
1176        let mut truncated_paths: usize = 0;
1177
1178        // Initial path starts at the target
1179        let initial: Vec<PathElem> = vec![(
1180            Arc::new(canon.clone()),
1181            Arc::from(symbol),
1182            target_line,
1183            target_sig,
1184        )];
1185
1186        // If the target itself is an entry point, record it as a trivial path
1187        if target_is_entry {
1188            complete_paths.push(initial.clone());
1189        }
1190
1191        // Queue of (current_path, depth)
1192        let mut queue: Vec<(Vec<PathElem>, usize)> = vec![(initial, 0)];
1193
1194        while let Some((path, depth)) = queue.pop() {
1195            if depth >= effective_max {
1196                max_depth_reached = true;
1197                continue;
1198            }
1199
1200            let Some((current_file, current_symbol, _, _)) = path.last() else {
1201                continue;
1202            };
1203
1204            // Look up callers in reverse index
1205            let callers = match self.reverse_sites(current_file.as_ref(), current_symbol.as_ref()) {
1206                Some(sites) => sites,
1207                None => {
1208                    // Dead end: no callers and not an entry point
1209                    // (if it were an entry point, we'd have recorded it already)
1210                    if path.len() > 1 {
1211                        // Only count as truncated if this isn't the target itself
1212                        // (the target with no callers is just "no paths found")
1213                        truncated_paths += 1;
1214                    }
1215                    continue;
1216                }
1217            };
1218
1219            let mut has_new_path = false;
1220            for site in callers {
1221                // Cycle detection: skip if this caller is already in the current path
1222                if path.iter().any(|(file_path, sym, _, _)| {
1223                    file_path.as_ref() == site.caller_file.as_ref()
1224                        && sym.as_ref() == site.caller_symbol.as_ref()
1225                }) {
1226                    continue;
1227                }
1228
1229                has_new_path = true;
1230
1231                // Get caller's metadata
1232                let (caller_line, caller_sig) = self
1233                    .lookup_file_data(site.caller_file.as_ref())
1234                    .map(|data| get_symbol_meta_from_data(data, site.caller_symbol.as_ref()))
1235                    .unwrap_or_else(|| {
1236                        get_symbol_meta(site.caller_file.as_ref(), site.caller_symbol.as_ref())
1237                    });
1238
1239                let mut new_path = path.clone();
1240                new_path.push((
1241                    Arc::clone(&site.caller_file),
1242                    Arc::clone(&site.caller_symbol),
1243                    caller_line,
1244                    caller_sig,
1245                ));
1246
1247                // Check if this caller is an entry point
1248                // Try both canonical and non-canonical keys (build_reverse_index
1249                // may have stored data under the raw walker path)
1250                let caller_is_entry = self
1251                    .lookup_file_data(site.caller_file.as_ref())
1252                    .and_then(|fd| {
1253                        let meta = fd.symbol_metadata.get(site.caller_symbol.as_ref())?;
1254                        Some(is_entry_point(
1255                            site.caller_symbol.as_ref(),
1256                            &meta.kind,
1257                            meta.exported,
1258                            fd.lang,
1259                        ))
1260                    })
1261                    .unwrap_or(false);
1262
1263                if caller_is_entry {
1264                    complete_paths.push(new_path.clone());
1265                }
1266                // Always continue searching backward — there may be longer
1267                // paths through other entry points beyond this one
1268                queue.push((new_path, depth + 1));
1269            }
1270
1271            // If we had callers but none were new (all cycles), count as truncated
1272            if !has_new_path && path.len() > 1 {
1273                truncated_paths += 1;
1274            }
1275        }
1276
1277        // Reverse each path so it reads top-down (entry point → ... → target)
1278        // and convert to TraceHop/TracePath
1279        let mut paths: Vec<TracePath> = complete_paths
1280            .into_iter()
1281            .map(|mut elems| {
1282                elems.reverse();
1283                let hops: Vec<TraceHop> = elems
1284                    .iter()
1285                    .enumerate()
1286                    .map(|(i, (file_path, sym, line, sig))| {
1287                        let is_ep = if i == 0 {
1288                            // First hop (after reverse) is the entry point
1289                            self.lookup_file_data(file_path.as_ref())
1290                                .and_then(|fd| {
1291                                    let meta = fd.symbol_metadata.get(sym.as_ref())?;
1292                                    Some(is_entry_point(
1293                                        sym.as_ref(),
1294                                        &meta.kind,
1295                                        meta.exported,
1296                                        fd.lang,
1297                                    ))
1298                                })
1299                                .unwrap_or(false)
1300                        } else {
1301                            false
1302                        };
1303                        TraceHop {
1304                            symbol: sym.to_string(),
1305                            file: self.relative_path(file_path.as_ref()),
1306                            line: *line,
1307                            signature: sig.clone(),
1308                            is_entry_point: is_ep,
1309                        }
1310                    })
1311                    .collect();
1312                TracePath { hops }
1313            })
1314            .collect();
1315
1316        // Sort paths for deterministic output (by entry point name, then path length)
1317        paths.sort_by(|a, b| {
1318            let a_entry = a.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1319            let b_entry = b.hops.first().map(|h| h.symbol.as_str()).unwrap_or("");
1320            a_entry.cmp(b_entry).then(a.hops.len().cmp(&b.hops.len()))
1321        });
1322
1323        // Count distinct entry points
1324        let mut entry_point_names: HashSet<String> = HashSet::new();
1325        for p in &paths {
1326            if let Some(first) = p.hops.first() {
1327                if first.is_entry_point {
1328                    entry_point_names.insert(first.symbol.clone());
1329                }
1330            }
1331        }
1332
1333        let total_paths = paths.len();
1334        let entry_points_found = entry_point_names.len();
1335
1336        Ok(TraceToResult {
1337            target_symbol: symbol.to_string(),
1338            target_file: target_rel,
1339            paths,
1340            total_paths,
1341            entry_points_found,
1342            max_depth_reached,
1343            truncated_paths,
1344        })
1345    }
1346
1347    /// Impact analysis: enriched callers query.
1348    ///
1349    /// Returns all call sites affected by a change to the given symbol,
1350    /// annotated with each caller's signature, entry point status, the
1351    /// source line at the call site, and extracted parameter names.
1352    pub fn impact(
1353        &mut self,
1354        file: &Path,
1355        symbol: &str,
1356        depth: usize,
1357        max_files: usize,
1358    ) -> Result<ImpactResult, AftError> {
1359        let canon = self.canonicalize(file)?;
1360
1361        // Ensure file is built
1362        self.build_file(&canon)?;
1363
1364        // Build the reverse index if not cached
1365        if self.reverse_index.is_none() {
1366            self.build_reverse_index(max_files)?;
1367        }
1368
1369        let effective_depth = if depth == 0 { 1 } else { depth };
1370
1371        // Get the target symbol's own metadata
1372        let (target_signature, target_parameters, target_lang) = {
1373            let file_data = match self.data.get(&canon) {
1374                Some(d) => d,
1375                None => {
1376                    return Err(AftError::InvalidRequest {
1377                        message: "file data missing after build".to_string(),
1378                    })
1379                }
1380            };
1381            let meta = file_data.symbol_metadata.get(symbol);
1382            let sig = meta.and_then(|m| m.signature.clone());
1383            let lang = file_data.lang;
1384            let params = sig
1385                .as_deref()
1386                .map(|s| extract_parameters(s, lang))
1387                .unwrap_or_default();
1388            (sig, params, lang)
1389        };
1390
1391        // Collect all caller sites (transitive)
1392        let mut visited = HashSet::new();
1393        let mut all_sites: Vec<CallerSite> = Vec::new();
1394        self.collect_callers_recursive(
1395            &canon,
1396            symbol,
1397            effective_depth,
1398            0,
1399            &mut visited,
1400            &mut all_sites,
1401        );
1402
1403        // Deduplicate sites by (file, symbol, line)
1404        let mut seen: HashSet<(PathBuf, String, u32)> = HashSet::new();
1405        all_sites.retain(|site| {
1406            seen.insert((
1407                site.caller_file.clone(),
1408                site.caller_symbol.clone(),
1409                site.line,
1410            ))
1411        });
1412
1413        // Enrich each caller site
1414        let mut callers = Vec::new();
1415        let mut affected_file_set = HashSet::new();
1416
1417        for site in &all_sites {
1418            // Build the caller's file to get metadata
1419            if let Err(e) = self.build_file(site.caller_file.as_path()) {
1420                log::debug!(
1421                    "callgraph: skipping caller file {}: {}",
1422                    site.caller_file.display(),
1423                    e
1424                );
1425            }
1426
1427            let (sig, is_ep, params, _lang) = {
1428                if let Some(fd) = self.lookup_file_data(site.caller_file.as_path()) {
1429                    let meta = fd.symbol_metadata.get(&site.caller_symbol);
1430                    let sig = meta.and_then(|m| m.signature.clone());
1431                    let kind = meta.map(|m| m.kind.clone()).unwrap_or(SymbolKind::Function);
1432                    let exported = meta.map(|m| m.exported).unwrap_or(false);
1433                    let is_ep = is_entry_point(&site.caller_symbol, &kind, exported, fd.lang);
1434                    let lang = fd.lang;
1435                    let params = sig
1436                        .as_deref()
1437                        .map(|s| extract_parameters(s, lang))
1438                        .unwrap_or_default();
1439                    (sig, is_ep, params, lang)
1440                } else {
1441                    (None, false, Vec::new(), target_lang)
1442                }
1443            };
1444
1445            // Read the source line at the call site
1446            let call_expression = self.read_source_line(site.caller_file.as_path(), site.line);
1447
1448            let rel_file = self.relative_path(site.caller_file.as_path());
1449            affected_file_set.insert(rel_file.clone());
1450
1451            callers.push(ImpactCaller {
1452                caller_symbol: site.caller_symbol.clone(),
1453                caller_file: rel_file,
1454                line: site.line,
1455                signature: sig,
1456                is_entry_point: is_ep,
1457                call_expression,
1458                parameters: params,
1459            });
1460        }
1461
1462        // Sort callers by file then line for deterministic output
1463        callers.sort_by(|a, b| a.caller_file.cmp(&b.caller_file).then(a.line.cmp(&b.line)));
1464
1465        let total_affected = callers.len();
1466        let affected_files = affected_file_set.len();
1467
1468        Ok(ImpactResult {
1469            symbol: symbol.to_string(),
1470            file: self.relative_path(&canon),
1471            signature: target_signature,
1472            parameters: target_parameters,
1473            total_affected,
1474            affected_files,
1475            callers,
1476        })
1477    }
1478
1479    /// Trace how an expression flows through variable assignments within a
1480    /// function body and across function boundaries via argument-to-parameter
1481    /// matching.
1482    ///
1483    /// Algorithm:
1484    /// 1. Parse the function body, find the expression text.
1485    /// 2. Walk AST for assignments that reference the tracked name.
1486    /// 3. When the tracked name appears as a call argument, resolve the callee,
1487    ///    match argument position to parameter name, recurse.
1488    /// 4. Destructuring, spread, and unresolved calls produce approximate hops.
1489    pub fn trace_data(
1490        &mut self,
1491        file: &Path,
1492        symbol: &str,
1493        expression: &str,
1494        max_depth: usize,
1495        max_files: usize,
1496    ) -> Result<TraceDataResult, AftError> {
1497        let canon = self.canonicalize(file)?;
1498        let rel_file = self.relative_path(&canon);
1499
1500        // Ensure file data is built
1501        self.build_file(&canon)?;
1502
1503        // Verify symbol exists
1504        {
1505            let fd = match self.data.get(&canon) {
1506                Some(d) => d,
1507                None => {
1508                    return Err(AftError::InvalidRequest {
1509                        message: "file data missing after build".to_string(),
1510                    })
1511                }
1512            };
1513            let has_symbol = fd.calls_by_symbol.contains_key(symbol)
1514                || fd.exported_symbols.iter().any(|name| name == symbol)
1515                || fd.symbol_metadata.contains_key(symbol);
1516            if !has_symbol {
1517                return Err(AftError::InvalidRequest {
1518                    message: format!(
1519                        "trace_data: symbol '{}' not found in {}",
1520                        symbol,
1521                        file.display()
1522                    ),
1523                });
1524            }
1525        }
1526
1527        // Bounded count: short-circuits at `max_files + 1` so oversized roots
1528        // reject in microseconds instead of paying the full walk/collect cost.
1529        // Matches the guard used by build_reverse_index / callers_of / trace_to / impact.
1530        let count = self.project_file_count_bounded(max_files);
1531        if count > max_files {
1532            return Err(AftError::ProjectTooLarge {
1533                count,
1534                max: max_files,
1535            });
1536        }
1537
1538        let mut hops = Vec::new();
1539        let mut depth_limited = false;
1540
1541        self.trace_data_inner(
1542            &canon,
1543            symbol,
1544            expression,
1545            max_depth,
1546            0,
1547            &mut hops,
1548            &mut depth_limited,
1549            &mut HashSet::new(),
1550        );
1551
1552        Ok(TraceDataResult {
1553            expression: expression.to_string(),
1554            origin_file: rel_file,
1555            origin_symbol: symbol.to_string(),
1556            hops,
1557            depth_limited,
1558        })
1559    }
1560
1561    /// Inner recursive data flow tracking.
1562    fn trace_data_inner(
1563        &mut self,
1564        file: &Path,
1565        symbol: &str,
1566        tracking_name: &str,
1567        max_depth: usize,
1568        current_depth: usize,
1569        hops: &mut Vec<DataFlowHop>,
1570        depth_limited: &mut bool,
1571        visited: &mut HashSet<(PathBuf, String, String)>,
1572    ) {
1573        let visit_key = (
1574            file.to_path_buf(),
1575            symbol.to_string(),
1576            tracking_name.to_string(),
1577        );
1578        if visited.contains(&visit_key) {
1579            return; // cycle
1580        }
1581        visited.insert(visit_key);
1582
1583        // Read and parse the file
1584        let source = match std::fs::read_to_string(file) {
1585            Ok(s) => s,
1586            Err(_) => return,
1587        };
1588
1589        let lang = match detect_language(file) {
1590            Some(l) => l,
1591            None => return,
1592        };
1593
1594        let grammar = grammar_for(lang);
1595        let mut parser = Parser::new();
1596        if parser.set_language(&grammar).is_err() {
1597            return;
1598        }
1599        let tree = match parser.parse(&source, None) {
1600            Some(t) => t,
1601            None => return,
1602        };
1603
1604        // Find the symbol's AST node range
1605        let symbols = match crate::parser::extract_symbols_from_tree(&source, &tree, lang) {
1606            Ok(symbols) => symbols,
1607            Err(_) => return,
1608        };
1609        let sym_info = match symbols.iter().find(|s| s.name == symbol) {
1610            Some(s) => s,
1611            None => return,
1612        };
1613
1614        let body_start =
1615            line_col_to_byte(&source, sym_info.range.start_line, sym_info.range.start_col);
1616        let body_end = line_col_to_byte(&source, sym_info.range.end_line, sym_info.range.end_col);
1617
1618        let root = tree.root_node();
1619
1620        // Find the symbol's body node (the function/method definition node)
1621        let body_node = match find_node_covering_range(root, body_start, body_end) {
1622            Some(n) => n,
1623            None => return,
1624        };
1625
1626        // Track names through the body
1627        let mut tracked_names: Vec<String> = vec![tracking_name.to_string()];
1628        let rel_file = self.relative_path(file);
1629
1630        // Walk the body looking for assignments and calls
1631        self.walk_for_data_flow(
1632            body_node,
1633            &source,
1634            &mut tracked_names,
1635            file,
1636            symbol,
1637            &rel_file,
1638            lang,
1639            max_depth,
1640            current_depth,
1641            hops,
1642            depth_limited,
1643            visited,
1644        );
1645    }
1646
1647    /// Walk an AST subtree looking for assignments and call expressions that
1648    /// reference tracked names.
1649    #[allow(clippy::too_many_arguments)]
1650    fn walk_for_data_flow(
1651        &mut self,
1652        node: tree_sitter::Node,
1653        source: &str,
1654        tracked_names: &mut Vec<String>,
1655        file: &Path,
1656        symbol: &str,
1657        rel_file: &str,
1658        lang: LangId,
1659        max_depth: usize,
1660        current_depth: usize,
1661        hops: &mut Vec<DataFlowHop>,
1662        depth_limited: &mut bool,
1663        visited: &mut HashSet<(PathBuf, String, String)>,
1664    ) {
1665        let kind = node.kind();
1666
1667        // Check for variable declarations / assignments
1668        let is_var_decl = matches!(
1669            kind,
1670            "variable_declarator"
1671                | "assignment_expression"
1672                | "augmented_assignment_expression"
1673                | "assignment"
1674                | "let_declaration"
1675                | "short_var_declaration"
1676        );
1677
1678        if is_var_decl {
1679            if let Some((new_name, init_text, line, is_approx)) =
1680                self.extract_assignment_info(node, source, lang, tracked_names)
1681            {
1682                // The RHS references a tracked name — add assignment hop
1683                if !is_approx {
1684                    hops.push(DataFlowHop {
1685                        file: rel_file.to_string(),
1686                        symbol: symbol.to_string(),
1687                        variable: new_name.clone(),
1688                        line,
1689                        flow_type: "assignment".to_string(),
1690                        approximate: false,
1691                    });
1692                    tracked_names.push(new_name);
1693                } else {
1694                    // Destructuring or pattern — approximate
1695                    hops.push(DataFlowHop {
1696                        file: rel_file.to_string(),
1697                        symbol: symbol.to_string(),
1698                        variable: init_text,
1699                        line,
1700                        flow_type: "assignment".to_string(),
1701                        approximate: true,
1702                    });
1703                    // Don't track further through this branch
1704                    return;
1705                }
1706            }
1707        }
1708
1709        // Check for call expressions where tracked name is an argument
1710        if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
1711            self.check_call_for_data_flow(
1712                node,
1713                source,
1714                tracked_names,
1715                file,
1716                symbol,
1717                rel_file,
1718                lang,
1719                max_depth,
1720                current_depth,
1721                hops,
1722                depth_limited,
1723                visited,
1724            );
1725        }
1726
1727        // Recurse into children
1728        let mut cursor = node.walk();
1729        if cursor.goto_first_child() {
1730            loop {
1731                let child = cursor.node();
1732                // Don't re-process the current node type in recursion
1733                self.walk_for_data_flow(
1734                    child,
1735                    source,
1736                    tracked_names,
1737                    file,
1738                    symbol,
1739                    rel_file,
1740                    lang,
1741                    max_depth,
1742                    current_depth,
1743                    hops,
1744                    depth_limited,
1745                    visited,
1746                );
1747                if !cursor.goto_next_sibling() {
1748                    break;
1749                }
1750            }
1751        }
1752    }
1753
1754    /// Check if an assignment/declaration node assigns from a tracked name.
1755    /// Returns (new_name, init_text, line, is_approximate).
1756    fn extract_assignment_info(
1757        &self,
1758        node: tree_sitter::Node,
1759        source: &str,
1760        _lang: LangId,
1761        tracked_names: &[String],
1762    ) -> Option<(String, String, u32, bool)> {
1763        let kind = node.kind();
1764        let line = node.start_position().row as u32 + 1;
1765
1766        match kind {
1767            "variable_declarator" => {
1768                // TS/JS: const x = <expr>
1769                let name_node = node.child_by_field_name("name")?;
1770                let value_node = node.child_by_field_name("value")?;
1771                let name_text = node_text(name_node, source);
1772                let value_text = node_text(value_node, source);
1773
1774                // Check if name is a destructuring pattern
1775                if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
1776                    // Check if value references a tracked name
1777                    if tracked_names.iter().any(|t| value_text.contains(t)) {
1778                        return Some((name_text.clone(), name_text, line, true));
1779                    }
1780                    return None;
1781                }
1782
1783                // Check if value references any tracked name
1784                if tracked_names.iter().any(|t| {
1785                    value_text == *t
1786                        || value_text.starts_with(&format!("{}.", t))
1787                        || value_text.starts_with(&format!("{}[", t))
1788                }) {
1789                    return Some((name_text, value_text, line, false));
1790                }
1791                None
1792            }
1793            "assignment_expression" | "augmented_assignment_expression" => {
1794                // TS/JS: x = <expr>
1795                let left = node.child_by_field_name("left")?;
1796                let right = node.child_by_field_name("right")?;
1797                let left_text = node_text(left, source);
1798                let right_text = node_text(right, source);
1799
1800                if tracked_names.iter().any(|t| right_text == *t) {
1801                    return Some((left_text, right_text, line, false));
1802                }
1803                None
1804            }
1805            "assignment" => {
1806                // Python: x = <expr>
1807                let left = node.child_by_field_name("left")?;
1808                let right = node.child_by_field_name("right")?;
1809                let left_text = node_text(left, source);
1810                let right_text = node_text(right, source);
1811
1812                if tracked_names.iter().any(|t| right_text == *t) {
1813                    return Some((left_text, right_text, line, false));
1814                }
1815                None
1816            }
1817            "let_declaration" | "short_var_declaration" => {
1818                // Rust / Go
1819                let left = node
1820                    .child_by_field_name("pattern")
1821                    .or_else(|| node.child_by_field_name("left"))?;
1822                let right = node
1823                    .child_by_field_name("value")
1824                    .or_else(|| node.child_by_field_name("right"))?;
1825                let left_text = node_text(left, source);
1826                let right_text = node_text(right, source);
1827
1828                if tracked_names.iter().any(|t| right_text == *t) {
1829                    return Some((left_text, right_text, line, false));
1830                }
1831                None
1832            }
1833            _ => None,
1834        }
1835    }
1836
1837    /// Check if a call expression uses a tracked name as an argument, and if so,
1838    /// resolve the callee and recurse into its body tracking the parameter name.
1839    #[allow(clippy::too_many_arguments)]
1840    fn check_call_for_data_flow(
1841        &mut self,
1842        node: tree_sitter::Node,
1843        source: &str,
1844        tracked_names: &[String],
1845        file: &Path,
1846        _symbol: &str,
1847        rel_file: &str,
1848        _lang: LangId,
1849        max_depth: usize,
1850        current_depth: usize,
1851        hops: &mut Vec<DataFlowHop>,
1852        depth_limited: &mut bool,
1853        visited: &mut HashSet<(PathBuf, String, String)>,
1854    ) {
1855        // Find the arguments node
1856        let args_node = find_child_by_kind(node, "arguments")
1857            .or_else(|| find_child_by_kind(node, "argument_list"));
1858
1859        let args_node = match args_node {
1860            Some(n) => n,
1861            None => return,
1862        };
1863
1864        // Collect argument texts and find which position a tracked name appears at
1865        let mut arg_positions: Vec<(usize, String)> = Vec::new(); // (position, tracked_name)
1866        let mut arg_idx = 0;
1867
1868        let mut cursor = args_node.walk();
1869        if cursor.goto_first_child() {
1870            loop {
1871                let child = cursor.node();
1872                let child_kind = child.kind();
1873
1874                // Skip punctuation (parentheses, commas)
1875                if child_kind == "(" || child_kind == ")" || child_kind == "," {
1876                    if !cursor.goto_next_sibling() {
1877                        break;
1878                    }
1879                    continue;
1880                }
1881
1882                let arg_text = node_text(child, source);
1883
1884                // Check for spread element — approximate
1885                if child_kind == "spread_element" || child_kind == "dictionary_splat" {
1886                    if tracked_names.iter().any(|t| arg_text.contains(t)) {
1887                        hops.push(DataFlowHop {
1888                            file: rel_file.to_string(),
1889                            symbol: _symbol.to_string(),
1890                            variable: arg_text,
1891                            line: child.start_position().row as u32 + 1,
1892                            flow_type: "parameter".to_string(),
1893                            approximate: true,
1894                        });
1895                    }
1896                    if !cursor.goto_next_sibling() {
1897                        break;
1898                    }
1899                    arg_idx += 1;
1900                    continue;
1901                }
1902
1903                if tracked_names.iter().any(|t| arg_text == *t) {
1904                    arg_positions.push((arg_idx, arg_text));
1905                }
1906
1907                arg_idx += 1;
1908                if !cursor.goto_next_sibling() {
1909                    break;
1910                }
1911            }
1912        }
1913
1914        if arg_positions.is_empty() {
1915            return;
1916        }
1917
1918        // Resolve the callee
1919        let (full_callee, short_callee) = extract_callee_names(node, source);
1920        let full_callee = match full_callee {
1921            Some(f) => f,
1922            None => return,
1923        };
1924        let short_callee = match short_callee {
1925            Some(s) => s,
1926            None => return,
1927        };
1928
1929        // Try to resolve cross-file edge
1930        let import_block = {
1931            match self.data.get(file) {
1932                Some(fd) => fd.import_block.clone(),
1933                None => return,
1934            }
1935        };
1936
1937        let edge = self.resolve_cross_file_edge(&full_callee, &short_callee, file, &import_block);
1938
1939        match edge {
1940            EdgeResolution::Resolved {
1941                file: target_file,
1942                symbol: target_symbol,
1943            } => {
1944                if current_depth + 1 > max_depth {
1945                    *depth_limited = true;
1946                    return;
1947                }
1948
1949                // Build target file to get parameter info
1950                if let Err(e) = self.build_file(&target_file) {
1951                    log::debug!(
1952                        "callgraph: skipping target file {}: {}",
1953                        target_file.display(),
1954                        e
1955                    );
1956                }
1957                let (params, target_line) = {
1958                    match self.lookup_file_data(&target_file) {
1959                        Some(fd) => {
1960                            let meta = fd.symbol_metadata.get(&target_symbol);
1961                            let sig = meta.and_then(|m| m.signature.clone());
1962                            let params = sig
1963                                .as_deref()
1964                                .map(|s| extract_parameters(s, fd.lang))
1965                                .unwrap_or_default();
1966                            let line = meta.map(|m| m.line).unwrap_or(1);
1967                            (params, line)
1968                        }
1969                        None => return,
1970                    }
1971                };
1972
1973                let target_rel = self.relative_path(&target_file);
1974
1975                for (pos, _tracked) in &arg_positions {
1976                    if let Some(param_name) = params.get(*pos) {
1977                        // Add parameter hop
1978                        hops.push(DataFlowHop {
1979                            file: target_rel.clone(),
1980                            symbol: target_symbol.clone(),
1981                            variable: param_name.clone(),
1982                            line: target_line,
1983                            flow_type: "parameter".to_string(),
1984                            approximate: false,
1985                        });
1986
1987                        // Recurse into callee's body tracking the parameter name
1988                        self.trace_data_inner(
1989                            &target_file.clone(),
1990                            &target_symbol.clone(),
1991                            param_name,
1992                            max_depth,
1993                            current_depth + 1,
1994                            hops,
1995                            depth_limited,
1996                            visited,
1997                        );
1998                    }
1999                }
2000            }
2001            EdgeResolution::Unresolved { callee_name } => {
2002                // Check if it's a same-file call
2003                let has_local = self
2004                    .data
2005                    .get(file)
2006                    .map(|fd| {
2007                        fd.calls_by_symbol.contains_key(&callee_name)
2008                            || fd.symbol_metadata.contains_key(&callee_name)
2009                    })
2010                    .unwrap_or(false);
2011
2012                if has_local {
2013                    // Same-file call — get param info
2014                    let (params, target_line) = {
2015                        let Some(fd) = self.data.get(file) else {
2016                            return;
2017                        };
2018                        let meta = fd.symbol_metadata.get(&callee_name);
2019                        let sig = meta.and_then(|m| m.signature.clone());
2020                        let params = sig
2021                            .as_deref()
2022                            .map(|s| extract_parameters(s, fd.lang))
2023                            .unwrap_or_default();
2024                        let line = meta.map(|m| m.line).unwrap_or(1);
2025                        (params, line)
2026                    };
2027
2028                    let file_rel = self.relative_path(file);
2029
2030                    for (pos, _tracked) in &arg_positions {
2031                        if let Some(param_name) = params.get(*pos) {
2032                            hops.push(DataFlowHop {
2033                                file: file_rel.clone(),
2034                                symbol: callee_name.clone(),
2035                                variable: param_name.clone(),
2036                                line: target_line,
2037                                flow_type: "parameter".to_string(),
2038                                approximate: false,
2039                            });
2040
2041                            // Recurse into same-file function
2042                            self.trace_data_inner(
2043                                file,
2044                                &callee_name.clone(),
2045                                param_name,
2046                                max_depth,
2047                                current_depth + 1,
2048                                hops,
2049                                depth_limited,
2050                                visited,
2051                            );
2052                        }
2053                    }
2054                } else {
2055                    // Truly unresolved — approximate hop
2056                    for (_pos, tracked) in &arg_positions {
2057                        hops.push(DataFlowHop {
2058                            file: self.relative_path(file),
2059                            symbol: callee_name.clone(),
2060                            variable: tracked.clone(),
2061                            line: node.start_position().row as u32 + 1,
2062                            flow_type: "parameter".to_string(),
2063                            approximate: true,
2064                        });
2065                    }
2066                }
2067            }
2068        }
2069    }
2070
2071    /// Read a single source line (1-based) from a file, trimmed.
2072    fn read_source_line(&self, path: &Path, line: u32) -> Option<String> {
2073        let content = std::fs::read_to_string(path).ok()?;
2074        content
2075            .lines()
2076            .nth(line.saturating_sub(1) as usize)
2077            .map(|l| l.trim().to_string())
2078    }
2079
2080    /// Recursively collect callers up to the given depth.
2081    fn collect_callers_recursive(
2082        &self,
2083        file: &Path,
2084        symbol: &str,
2085        max_depth: usize,
2086        current_depth: usize,
2087        visited: &mut HashSet<(PathBuf, SharedStr)>,
2088        result: &mut Vec<CallerSite>,
2089    ) {
2090        if current_depth >= max_depth {
2091            return;
2092        }
2093
2094        // Canonicalize for consistent reverse index lookup
2095        let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
2096        let key_symbol: SharedStr = Arc::from(symbol);
2097        if !visited.insert((canon.clone(), Arc::clone(&key_symbol))) {
2098            return; // cycle detection
2099        }
2100
2101        if let Some(sites) = self.reverse_sites(&canon, key_symbol.as_ref()) {
2102            for site in sites {
2103                result.push(CallerSite {
2104                    caller_file: site.caller_file.as_ref().clone(),
2105                    caller_symbol: site.caller_symbol.to_string(),
2106                    line: site.line,
2107                    col: site.col,
2108                    resolved: site.resolved,
2109                });
2110                // Recurse: find callers of the caller
2111                if current_depth + 1 < max_depth {
2112                    self.collect_callers_recursive(
2113                        site.caller_file.as_ref(),
2114                        site.caller_symbol.as_ref(),
2115                        max_depth,
2116                        current_depth + 1,
2117                        visited,
2118                        result,
2119                    );
2120                }
2121            }
2122        }
2123    }
2124
2125    /// Invalidate a file: remove its cached data and clear the reverse index.
2126    ///
2127    /// Called by the file watcher when a file changes on disk. The reverse
2128    /// index is rebuilt lazily on the next `callers_of` call.
2129    pub fn invalidate_file(&mut self, path: &Path) {
2130        // Remove from data cache (try both as-is and canonicalized)
2131        self.data.remove(path);
2132        if let Ok(canon) = self.canonicalize(path) {
2133            self.data.remove(&canon);
2134        }
2135        // Clear the reverse index — it's stale
2136        self.reverse_index = None;
2137        // Clear project_files cache for create/remove events
2138        self.project_files = None;
2139        clear_workspace_package_cache();
2140    }
2141
2142    /// Return a path relative to the project root, or the absolute path if
2143    /// it's outside the project.
2144    fn relative_path(&self, path: &Path) -> String {
2145        path.strip_prefix(&self.project_root)
2146            .unwrap_or(path)
2147            .display()
2148            .to_string()
2149    }
2150
2151    /// Canonicalize a path, falling back to the original if canonicalization fails.
2152    fn canonicalize(&self, path: &Path) -> Result<PathBuf, AftError> {
2153        // If the path is relative, resolve it against project_root
2154        let full_path = if path.is_relative() {
2155            self.project_root.join(path)
2156        } else {
2157            path.to_path_buf()
2158        };
2159
2160        // Try canonicalize, fall back to the full path
2161        Ok(std::fs::canonicalize(&full_path).unwrap_or(full_path))
2162    }
2163
2164    /// Look up cached file data, trying both the given path and its
2165    /// canonicalized form. Needed because `build_reverse_index` may store
2166    /// data under raw walker paths while CallerSite uses canonical paths.
2167    fn lookup_file_data(&self, path: &Path) -> Option<&FileCallData> {
2168        if let Some(fd) = self.data.get(path) {
2169            return Some(fd);
2170        }
2171        // Try canonical
2172        let canon = std::fs::canonicalize(path).ok()?;
2173        self.data.get(&canon).or_else(|| {
2174            // Try non-canonical forms stored by the walker
2175            self.data.iter().find_map(|(k, v)| {
2176                if std::fs::canonicalize(k).ok().as_ref() == Some(&canon) {
2177                    Some(v)
2178                } else {
2179                    None
2180                }
2181            })
2182        })
2183    }
2184}
2185
2186// ---------------------------------------------------------------------------
2187// File-level building
2188// ---------------------------------------------------------------------------
2189
2190/// Build call data for a single file.
2191fn build_file_data(path: &Path) -> Result<FileCallData, AftError> {
2192    let lang = detect_language(path).ok_or_else(|| AftError::InvalidRequest {
2193        message: format!("unsupported file for call graph: {}", path.display()),
2194    })?;
2195
2196    let source = std::fs::read_to_string(path).map_err(|e| AftError::FileNotFound {
2197        path: format!("{}: {}", path.display(), e),
2198    })?;
2199
2200    let grammar = grammar_for(lang);
2201    let mut parser = Parser::new();
2202    parser
2203        .set_language(&grammar)
2204        .map_err(|e| AftError::ParseError {
2205            message: format!("grammar init failed for {:?}: {}", lang, e),
2206        })?;
2207
2208    let tree = parser
2209        .parse(&source, None)
2210        .ok_or_else(|| AftError::ParseError {
2211            message: format!("parse failed for {}", path.display()),
2212        })?;
2213
2214    // Parse imports
2215    let import_block = imports::parse_imports(&source, &tree, lang);
2216
2217    // Get symbols (for call site extraction and export detection)
2218    let symbols = crate::parser::extract_symbols_from_tree(&source, &tree, lang)?;
2219
2220    // Build calls_by_symbol
2221    let mut calls_by_symbol: HashMap<String, Vec<CallSite>> = HashMap::new();
2222    let root = tree.root_node();
2223
2224    for sym in &symbols {
2225        let byte_start = line_col_to_byte(&source, sym.range.start_line, sym.range.start_col);
2226        let byte_end = line_col_to_byte(&source, sym.range.end_line, sym.range.end_col);
2227
2228        let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2229
2230        let sites: Vec<CallSite> = raw_calls
2231            .into_iter()
2232            .map(|(full, short, line)| CallSite {
2233                callee_name: short,
2234                full_callee: full,
2235                line,
2236                byte_start,
2237                byte_end,
2238            })
2239            .collect();
2240
2241        if !sites.is_empty() {
2242            calls_by_symbol.insert(sym.name.clone(), sites);
2243        }
2244    }
2245
2246    let symbol_ranges: Vec<(usize, usize)> = symbols
2247        .iter()
2248        .map(|sym| {
2249            (
2250                line_col_to_byte(&source, sym.range.start_line, sym.range.start_col),
2251                line_col_to_byte(&source, sym.range.end_line, sym.range.end_col),
2252            )
2253        })
2254        .collect();
2255
2256    let top_level_sites: Vec<CallSite> =
2257        collect_calls_full_with_ranges(root, &source, 0, source.len(), lang)
2258            .into_iter()
2259            .filter(|site| {
2260                !symbol_ranges
2261                    .iter()
2262                    .any(|(start, end)| site.byte_start >= *start && site.byte_end <= *end)
2263            })
2264            .map(|site| CallSite {
2265                callee_name: site.short,
2266                full_callee: site.full,
2267                line: site.line,
2268                byte_start: site.byte_start,
2269                byte_end: site.byte_end,
2270            })
2271            .collect();
2272
2273    if !top_level_sites.is_empty() {
2274        calls_by_symbol.insert(TOP_LEVEL_SYMBOL.to_string(), top_level_sites);
2275    }
2276
2277    let default_export = find_default_export(&source, root, path, lang);
2278
2279    if let Some(default_export) = &default_export {
2280        if default_export.synthetic {
2281            let byte_start = default_export.node.byte_range().start;
2282            let byte_end = default_export.node.byte_range().end;
2283            let raw_calls = extract_calls_full(&source, root, byte_start, byte_end, lang);
2284            let sites: Vec<CallSite> = raw_calls
2285                .into_iter()
2286                .filter(|(_, short, _)| *short != default_export.symbol)
2287                .map(|(full, short, line)| CallSite {
2288                    callee_name: short,
2289                    full_callee: full,
2290                    line,
2291                    byte_start,
2292                    byte_end,
2293                })
2294                .collect();
2295            if !sites.is_empty() {
2296                calls_by_symbol.insert(default_export.symbol.clone(), sites);
2297            }
2298        }
2299    }
2300
2301    // Collect exported symbol names
2302    let mut exported_symbols: Vec<String> = symbols
2303        .iter()
2304        .filter(|s| s.exported)
2305        .map(|s| s.name.clone())
2306        .collect();
2307    if let Some(default_export) = &default_export {
2308        if !exported_symbols
2309            .iter()
2310            .any(|name| name == &default_export.symbol)
2311        {
2312            exported_symbols.push(default_export.symbol.clone());
2313        }
2314    }
2315
2316    // Build per-symbol metadata for entry point detection
2317    let mut symbol_metadata: HashMap<String, SymbolMeta> = symbols
2318        .iter()
2319        .map(|s| {
2320            (
2321                s.name.clone(),
2322                SymbolMeta {
2323                    kind: s.kind.clone(),
2324                    exported: s.exported,
2325                    signature: s.signature.clone(),
2326                    line: s.range.start_line + 1,
2327                    range: s.range.clone(),
2328                },
2329            )
2330        })
2331        .collect();
2332    if let Some(default_export) = &default_export {
2333        symbol_metadata
2334            .entry(default_export.symbol.clone())
2335            .or_insert_with(|| SymbolMeta {
2336                kind: default_export.kind.clone(),
2337                exported: true,
2338                signature: Some(first_line_signature(&source, &default_export.node)),
2339                line: default_export.node.start_position().row as u32 + 1,
2340                range: crate::parser::node_range(&default_export.node),
2341            });
2342    }
2343    if calls_by_symbol.contains_key(TOP_LEVEL_SYMBOL) {
2344        symbol_metadata
2345            .entry(TOP_LEVEL_SYMBOL.to_string())
2346            .or_insert(SymbolMeta {
2347                kind: SymbolKind::Function,
2348                exported: false,
2349                signature: None,
2350                line: 1,
2351                range: Range {
2352                    start_line: 0,
2353                    start_col: 0,
2354                    end_line: 0,
2355                    end_col: 0,
2356                },
2357            });
2358    }
2359
2360    Ok(FileCallData {
2361        calls_by_symbol,
2362        exported_symbols,
2363        symbol_metadata,
2364        default_export_symbol: default_export.map(|export| export.symbol),
2365        import_block,
2366        lang,
2367    })
2368}
2369
2370#[derive(Debug, Clone)]
2371struct DefaultExport<'tree> {
2372    symbol: String,
2373    synthetic: bool,
2374    kind: SymbolKind,
2375    node: Node<'tree>,
2376}
2377
2378fn find_default_export<'tree>(
2379    source: &str,
2380    root: Node<'tree>,
2381    path: &Path,
2382    lang: LangId,
2383) -> Option<DefaultExport<'tree>> {
2384    if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
2385        return None;
2386    }
2387    find_default_export_inner(source, root, path)
2388}
2389
2390fn find_default_export_inner<'tree>(
2391    source: &str,
2392    node: Node<'tree>,
2393    path: &Path,
2394) -> Option<DefaultExport<'tree>> {
2395    if node.kind() == "export_statement" {
2396        if let Some(default_export) = default_export_from_statement(source, node, path) {
2397            return Some(default_export);
2398        }
2399    }
2400
2401    let mut cursor = node.walk();
2402    if !cursor.goto_first_child() {
2403        return None;
2404    }
2405
2406    loop {
2407        let child = cursor.node();
2408        if let Some(default_export) = find_default_export_inner(source, child, path) {
2409            return Some(default_export);
2410        }
2411        if !cursor.goto_next_sibling() {
2412            break;
2413        }
2414    }
2415
2416    None
2417}
2418
2419fn default_export_from_statement<'tree>(
2420    source: &str,
2421    node: Node<'tree>,
2422    path: &Path,
2423) -> Option<DefaultExport<'tree>> {
2424    let mut cursor = node.walk();
2425    if !cursor.goto_first_child() {
2426        return None;
2427    }
2428
2429    let mut saw_default = false;
2430    loop {
2431        let child = cursor.node();
2432        match child.kind() {
2433            "default" => saw_default = true,
2434            "function_declaration" | "generator_function_declaration" | "class_declaration"
2435                if saw_default =>
2436            {
2437                if let Some(name_node) = child.child_by_field_name("name") {
2438                    return Some(DefaultExport {
2439                        symbol: source[name_node.byte_range()].to_string(),
2440                        synthetic: false,
2441                        kind: default_export_kind(&child),
2442                        node: child,
2443                    });
2444                }
2445                return Some(DefaultExport {
2446                    symbol: synthetic_default_symbol(path),
2447                    synthetic: true,
2448                    kind: default_export_kind(&child),
2449                    node: child,
2450                });
2451            }
2452            "arrow_function"
2453            | "function"
2454            | "function_expression"
2455            | "class"
2456            | "class_expression"
2457                if saw_default =>
2458            {
2459                return Some(DefaultExport {
2460                    symbol: synthetic_default_symbol(path),
2461                    synthetic: true,
2462                    kind: default_export_kind(&child),
2463                    node: child,
2464                });
2465            }
2466            "identifier" | "type_identifier" | "property_identifier" if saw_default => {
2467                return Some(DefaultExport {
2468                    symbol: source[child.byte_range()].to_string(),
2469                    synthetic: false,
2470                    kind: SymbolKind::Function,
2471                    node: child,
2472                });
2473            }
2474            _ => {}
2475        }
2476        if !cursor.goto_next_sibling() {
2477            break;
2478        }
2479    }
2480
2481    None
2482}
2483
2484fn default_export_kind(node: &Node) -> SymbolKind {
2485    if node.kind().contains("class") {
2486        SymbolKind::Class
2487    } else {
2488        SymbolKind::Function
2489    }
2490}
2491
2492fn synthetic_default_symbol(path: &Path) -> String {
2493    let file_name = path
2494        .file_name()
2495        .and_then(|name| name.to_str())
2496        .unwrap_or("unknown");
2497    format!("<default:{file_name}>")
2498}
2499
2500fn first_line_signature(source: &str, node: &Node) -> String {
2501    let text = &source[node.byte_range()];
2502    let first_line = text.lines().next().unwrap_or(text);
2503    first_line
2504        .trim_end()
2505        .trim_end_matches('{')
2506        .trim_end()
2507        .to_string()
2508}
2509
2510fn get_symbol_meta_from_data(file_data: &FileCallData, symbol_name: &str) -> (u32, Option<String>) {
2511    file_data
2512        .symbol_metadata
2513        .get(symbol_name)
2514        .map(|meta| (meta.line, meta.signature.clone()))
2515        .unwrap_or((1, None))
2516}
2517
2518/// Get symbol metadata (line, signature) from a file.
2519fn get_symbol_meta(path: &Path, symbol_name: &str) -> (u32, Option<String>) {
2520    let provider = crate::parser::TreeSitterProvider::new();
2521    match provider.list_symbols(path) {
2522        Ok(symbols) => {
2523            for s in &symbols {
2524                if s.name == symbol_name {
2525                    return (s.range.start_line + 1, s.signature.clone());
2526                }
2527            }
2528            (1, None)
2529        }
2530        Err(_) => (1, None),
2531    }
2532}
2533
2534// ---------------------------------------------------------------------------
2535// Data flow tracking helpers
2536// ---------------------------------------------------------------------------
2537
2538/// Get the text of a tree-sitter node from the source.
2539fn node_text(node: tree_sitter::Node, source: &str) -> String {
2540    source[node.start_byte()..node.end_byte()].to_string()
2541}
2542
2543/// Find the smallest node that fully covers a byte range.
2544fn find_node_covering_range(
2545    root: tree_sitter::Node,
2546    start: usize,
2547    end: usize,
2548) -> Option<tree_sitter::Node> {
2549    let mut best = None;
2550    let mut cursor = root.walk();
2551
2552    fn walk_covering<'a>(
2553        cursor: &mut tree_sitter::TreeCursor<'a>,
2554        start: usize,
2555        end: usize,
2556        best: &mut Option<tree_sitter::Node<'a>>,
2557    ) {
2558        let node = cursor.node();
2559        if node.start_byte() <= start && node.end_byte() >= end {
2560            *best = Some(node);
2561            if cursor.goto_first_child() {
2562                loop {
2563                    walk_covering(cursor, start, end, best);
2564                    if !cursor.goto_next_sibling() {
2565                        break;
2566                    }
2567                }
2568                cursor.goto_parent();
2569            }
2570        }
2571    }
2572
2573    walk_covering(&mut cursor, start, end, &mut best);
2574    best
2575}
2576
2577/// Find a direct child node by kind name.
2578fn find_child_by_kind<'a>(
2579    node: tree_sitter::Node<'a>,
2580    kind: &str,
2581) -> Option<tree_sitter::Node<'a>> {
2582    let mut cursor = node.walk();
2583    if cursor.goto_first_child() {
2584        loop {
2585            if cursor.node().kind() == kind {
2586                return Some(cursor.node());
2587            }
2588            if !cursor.goto_next_sibling() {
2589                break;
2590            }
2591        }
2592    }
2593    None
2594}
2595
2596#[derive(Debug, Clone)]
2597struct CallSiteWithRange {
2598    full: String,
2599    short: String,
2600    line: u32,
2601    byte_start: usize,
2602    byte_end: usize,
2603}
2604
2605fn collect_calls_full_with_ranges(
2606    root: tree_sitter::Node,
2607    source: &str,
2608    byte_start: usize,
2609    byte_end: usize,
2610    lang: LangId,
2611) -> Vec<CallSiteWithRange> {
2612    let mut results = Vec::new();
2613    let call_kinds = call_node_kinds(lang);
2614    collect_calls_full_with_ranges_inner(
2615        root,
2616        source,
2617        byte_start,
2618        byte_end,
2619        &call_kinds,
2620        &mut results,
2621    );
2622    results
2623}
2624
2625fn collect_calls_full_with_ranges_inner(
2626    node: tree_sitter::Node,
2627    source: &str,
2628    byte_start: usize,
2629    byte_end: usize,
2630    call_kinds: &[&str],
2631    results: &mut Vec<CallSiteWithRange>,
2632) {
2633    let node_start = node.start_byte();
2634    let node_end = node.end_byte();
2635
2636    if node_end <= byte_start || node_start >= byte_end {
2637        return;
2638    }
2639
2640    if call_kinds.contains(&node.kind()) && node_start >= byte_start && node_end <= byte_end {
2641        if let (Some(full), Some(short)) = (
2642            extract_full_callee(&node, source),
2643            extract_callee_name(&node, source),
2644        ) {
2645            results.push(CallSiteWithRange {
2646                full,
2647                short,
2648                line: node.start_position().row as u32 + 1,
2649                byte_start: node_start,
2650                byte_end: node_end,
2651            });
2652        }
2653    }
2654
2655    let mut cursor = node.walk();
2656    if cursor.goto_first_child() {
2657        loop {
2658            collect_calls_full_with_ranges_inner(
2659                cursor.node(),
2660                source,
2661                byte_start,
2662                byte_end,
2663                call_kinds,
2664                results,
2665            );
2666            if !cursor.goto_next_sibling() {
2667                break;
2668            }
2669        }
2670    }
2671}
2672
2673/// Extract full and short callee names from a call_expression node.
2674fn extract_callee_names(node: tree_sitter::Node, source: &str) -> (Option<String>, Option<String>) {
2675    // The "function" field holds the callee
2676    let callee = match node.child_by_field_name("function") {
2677        Some(c) => c,
2678        None => return (None, None),
2679    };
2680
2681    let full = node_text(callee, source);
2682    let short = if full.contains('.') {
2683        full.rsplit('.').next().unwrap_or(&full).to_string()
2684    } else {
2685        full.clone()
2686    };
2687
2688    (Some(full), Some(short))
2689}
2690
2691// ---------------------------------------------------------------------------
2692// Module path resolution
2693// ---------------------------------------------------------------------------
2694
2695/// Resolve a module path (e.g. './utils') relative to a directory.
2696///
2697/// Tries common file extensions for TypeScript/JavaScript projects.
2698pub(crate) fn resolve_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2699    if module_path.starts_with('.') {
2700        return resolve_relative_module_path(from_dir, module_path);
2701    }
2702
2703    if module_path.starts_with('/') {
2704        return None;
2705    }
2706
2707    if let Some(path) = resolve_tsconfig_path(from_dir, module_path) {
2708        return Some(path);
2709    }
2710
2711    resolve_workspace_module_path(from_dir, module_path)
2712}
2713
2714fn resolve_relative_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2715    let base = from_dir.join(module_path);
2716    resolve_file_like_path(&base)
2717}
2718
2719fn resolve_file_like_path(base: &Path) -> Option<PathBuf> {
2720    let base = base.to_path_buf();
2721
2722    // Try exact path first
2723    if base.is_file() {
2724        return Some(std::fs::canonicalize(&base).unwrap_or(base));
2725    }
2726
2727    // Try common extensions
2728    let extensions = [".ts", ".tsx", ".js", ".jsx"];
2729    for ext in &extensions {
2730        let with_ext = base.with_extension(ext.trim_start_matches('.'));
2731        if with_ext.is_file() {
2732            return Some(std::fs::canonicalize(&with_ext).unwrap_or(with_ext));
2733        }
2734    }
2735
2736    // Try as directory with index file
2737    if base.is_dir() {
2738        if let Some(index) = find_index_file(&base) {
2739            return Some(index);
2740        }
2741    }
2742
2743    None
2744}
2745
2746fn resolve_workspace_module_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2747    let (package_name, subpath) = split_package_import(module_path)?;
2748    let package_root = find_package_root_for_import(from_dir, &package_name)?;
2749    resolve_package_entry(&package_root, &subpath)
2750}
2751
2752fn resolve_tsconfig_path(from_dir: &Path, module_path: &str) -> Option<PathBuf> {
2753    let tsconfig_dir = find_tsconfig_dir(from_dir)?;
2754    let tsconfig = package_json_like_value(&tsconfig_dir.join("tsconfig.json"))?;
2755    let compiler_options = tsconfig.get("compilerOptions")?;
2756    let paths = compiler_options.get("paths")?.as_object()?;
2757    let base_url = compiler_options
2758        .get("baseUrl")
2759        .and_then(Value::as_str)
2760        .unwrap_or(".");
2761    let base_dir = tsconfig_dir.join(base_url);
2762
2763    for (alias, targets) in paths {
2764        let Some(capture) = ts_path_capture(alias, module_path) else {
2765            continue;
2766        };
2767        let Some(targets) = targets.as_array() else {
2768            continue;
2769        };
2770        for target in targets.iter().filter_map(Value::as_str) {
2771            let target = if target.contains('*') {
2772                target.replace('*', capture)
2773            } else {
2774                target.to_string()
2775            };
2776            if let Some(path) = resolve_file_like_path(&base_dir.join(target)) {
2777                return Some(path);
2778            }
2779        }
2780    }
2781
2782    None
2783}
2784
2785fn find_tsconfig_dir(from_dir: &Path) -> Option<PathBuf> {
2786    let mut current = Some(from_dir);
2787    while let Some(dir) = current {
2788        if dir.join("tsconfig.json").is_file() {
2789            return Some(dir.to_path_buf());
2790        }
2791        current = dir.parent();
2792    }
2793    None
2794}
2795
2796fn ts_path_capture<'a>(alias: &str, module_path: &'a str) -> Option<&'a str> {
2797    if let Some(star_index) = alias.find('*') {
2798        let (prefix, suffix_with_star) = alias.split_at(star_index);
2799        let suffix = &suffix_with_star[1..];
2800        if module_path.starts_with(prefix) && module_path.ends_with(suffix) {
2801            return Some(&module_path[prefix.len()..module_path.len() - suffix.len()]);
2802        }
2803        return None;
2804    }
2805
2806    (alias == module_path).then_some("")
2807}
2808
2809fn split_package_import(module_path: &str) -> Option<(String, Option<String>)> {
2810    let mut parts = module_path.split('/');
2811    let first = parts.next()?;
2812    if first.is_empty() {
2813        return None;
2814    }
2815
2816    if first.starts_with('@') {
2817        let second = parts.next()?;
2818        if second.is_empty() {
2819            return None;
2820        }
2821        let package_name = format!("{first}/{second}");
2822        let subpath = parts.collect::<Vec<_>>().join("/");
2823        let subpath = (!subpath.is_empty()).then_some(subpath);
2824        Some((package_name, subpath))
2825    } else {
2826        let package_name = first.to_string();
2827        let subpath = parts.collect::<Vec<_>>().join("/");
2828        let subpath = (!subpath.is_empty()).then_some(subpath);
2829        Some((package_name, subpath))
2830    }
2831}
2832
2833fn find_package_root_for_import(from_dir: &Path, package_name: &str) -> Option<PathBuf> {
2834    let mut current = Some(from_dir);
2835    while let Some(dir) = current {
2836        if package_json_name(dir).as_deref() == Some(package_name) {
2837            return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
2838        }
2839        current = dir.parent();
2840    }
2841
2842    find_workspace_root(from_dir)
2843        .and_then(|workspace_root| resolve_workspace_package(&workspace_root, package_name))
2844}
2845
2846fn find_workspace_root(from_dir: &Path) -> Option<PathBuf> {
2847    let mut current = Some(from_dir);
2848    while let Some(dir) = current {
2849        if is_workspace_root(dir) {
2850            return Some(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()));
2851        }
2852        current = dir.parent();
2853    }
2854    None
2855}
2856
2857fn is_workspace_root(dir: &Path) -> bool {
2858    package_json_value(dir)
2859        .map(|value| !workspace_patterns(&value).is_empty())
2860        .unwrap_or(false)
2861        || !pnpm_workspace_patterns(dir).is_empty()
2862}
2863
2864fn clear_workspace_package_cache() {
2865    if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2866        cache.clear();
2867    }
2868}
2869
2870fn resolve_workspace_package(workspace_root: &Path, package_name: &str) -> Option<PathBuf> {
2871    let workspace_root =
2872        std::fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf());
2873    let cache_key = (workspace_root.clone(), package_name.to_string());
2874
2875    if let Some(cached) = WORKSPACE_PACKAGE_CACHE
2876        .read()
2877        .ok()
2878        .and_then(|cache| cache.get(&cache_key).cloned())
2879    {
2880        return cached;
2881    }
2882
2883    let resolved = workspace_member_dirs(&workspace_root)
2884        .into_iter()
2885        .find(|dir| package_json_name(dir).as_deref() == Some(package_name))
2886        .map(|dir| std::fs::canonicalize(&dir).unwrap_or(dir));
2887
2888    if let Ok(mut cache) = WORKSPACE_PACKAGE_CACHE.write() {
2889        cache.insert(cache_key, resolved.clone());
2890    }
2891
2892    resolved
2893}
2894
2895fn workspace_member_dirs(workspace_root: &Path) -> Vec<PathBuf> {
2896    let mut patterns = package_json_value(workspace_root)
2897        .map(|package_json| workspace_patterns(&package_json))
2898        .unwrap_or_default();
2899    patterns.extend(pnpm_workspace_patterns(workspace_root));
2900
2901    expand_workspace_patterns(workspace_root, &patterns)
2902}
2903
2904fn workspace_patterns(package_json: &Value) -> Vec<String> {
2905    match package_json.get("workspaces") {
2906        Some(Value::Array(items)) => items
2907            .iter()
2908            .filter_map(non_empty_workspace_pattern)
2909            .collect(),
2910        Some(Value::Object(map)) => map
2911            .get("packages")
2912            .and_then(Value::as_array)
2913            .map(|items| {
2914                items
2915                    .iter()
2916                    .filter_map(non_empty_workspace_pattern)
2917                    .collect()
2918            })
2919            .unwrap_or_default(),
2920        _ => Vec::new(),
2921    }
2922}
2923
2924fn non_empty_workspace_pattern(value: &Value) -> Option<String> {
2925    let pattern = value.as_str()?.trim();
2926    (!pattern.is_empty()).then(|| pattern.to_string())
2927}
2928
2929fn pnpm_workspace_patterns(workspace_root: &Path) -> Vec<String> {
2930    let Ok(source) = std::fs::read_to_string(workspace_root.join("pnpm-workspace.yaml")) else {
2931        return Vec::new();
2932    };
2933
2934    let mut patterns = Vec::new();
2935    let mut in_packages = false;
2936    for line in source.lines() {
2937        let without_comment = line.split('#').next().unwrap_or("").trim_end();
2938        let trimmed = without_comment.trim();
2939        if trimmed.is_empty() {
2940            continue;
2941        }
2942        if trimmed == "packages:" {
2943            in_packages = true;
2944            continue;
2945        }
2946        if !trimmed.starts_with('-') && !line.starts_with(' ') && !line.starts_with('\t') {
2947            in_packages = false;
2948        }
2949        if in_packages {
2950            if let Some(pattern) = trimmed.strip_prefix('-') {
2951                let pattern = pattern.trim().trim_matches('"').trim_matches('\'');
2952                if !pattern.is_empty() {
2953                    patterns.push(pattern.to_string());
2954                }
2955            }
2956        }
2957    }
2958    patterns
2959}
2960
2961fn expand_workspace_patterns(workspace_root: &Path, patterns: &[String]) -> Vec<PathBuf> {
2962    let positive_patterns: Vec<&str> = patterns
2963        .iter()
2964        .map(|pattern| pattern.trim())
2965        .filter(|pattern| !pattern.is_empty() && !pattern.starts_with('!'))
2966        .collect();
2967    if positive_patterns.is_empty() {
2968        return Vec::new();
2969    }
2970
2971    let positives = build_glob_set(&positive_patterns);
2972    let negative_patterns: Vec<&str> = patterns
2973        .iter()
2974        .map(|pattern| pattern.trim())
2975        .filter_map(|pattern| pattern.strip_prefix('!'))
2976        .map(str::trim)
2977        .filter(|pattern| !pattern.is_empty())
2978        .collect();
2979    let negatives = build_glob_set(&negative_patterns);
2980
2981    let mut members = Vec::new();
2982    collect_workspace_member_dirs(
2983        workspace_root,
2984        workspace_root,
2985        &positives,
2986        &negatives,
2987        &mut members,
2988    );
2989    members
2990}
2991
2992fn build_glob_set(patterns: &[&str]) -> GlobSet {
2993    let mut builder = GlobSetBuilder::new();
2994    for pattern in patterns {
2995        if let Ok(glob) = Glob::new(pattern) {
2996            builder.add(glob);
2997        }
2998    }
2999    builder
3000        .build()
3001        .unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap())
3002}
3003
3004fn collect_workspace_member_dirs(
3005    workspace_root: &Path,
3006    dir: &Path,
3007    positives: &GlobSet,
3008    negatives: &GlobSet,
3009    members: &mut Vec<PathBuf>,
3010) {
3011    let Ok(entries) = std::fs::read_dir(dir) else {
3012        return;
3013    };
3014
3015    for entry in entries.filter_map(Result::ok) {
3016        let path = entry.path();
3017        let Ok(file_type) = entry.file_type() else {
3018            continue;
3019        };
3020        if !file_type.is_dir() {
3021            continue;
3022        }
3023        let name = entry.file_name();
3024        let name = name.to_string_lossy();
3025        if matches!(
3026            name.as_ref(),
3027            "node_modules" | ".git" | "target" | "dist" | "build"
3028        ) {
3029            continue;
3030        }
3031
3032        if path.join("package.json").is_file() {
3033            if let Ok(rel) = path.strip_prefix(workspace_root) {
3034                let rel = rel.to_string_lossy().replace('\\', "/");
3035                if positives.is_match(&rel) && !negatives.is_match(&rel) {
3036                    members.push(path.clone());
3037                }
3038            }
3039        }
3040
3041        collect_workspace_member_dirs(workspace_root, &path, positives, negatives, members);
3042    }
3043}
3044
3045fn package_json_value(dir: &Path) -> Option<Value> {
3046    package_json_like_value(&dir.join("package.json"))
3047}
3048
3049fn package_json_like_value(path: &Path) -> Option<Value> {
3050    let json = std::fs::read_to_string(path).ok()?;
3051    serde_json::from_str(&json).ok()
3052}
3053
3054fn package_json_name(dir: &Path) -> Option<String> {
3055    package_json_value(dir)?
3056        .get("name")?
3057        .as_str()
3058        .map(ToOwned::to_owned)
3059}
3060
3061fn resolve_package_entry(package_root: &Path, subpath: &Option<String>) -> Option<PathBuf> {
3062    let package_json = package_json_value(package_root).unwrap_or(Value::Null);
3063
3064    if let Some(exports) = package_json.get("exports") {
3065        if let Some(target) = export_target_for_subpath(exports, subpath.as_deref()) {
3066            if let Some(path) = resolve_package_target(package_root, &target) {
3067                return Some(path);
3068            }
3069        }
3070    }
3071
3072    if subpath.is_none() {
3073        for field in ["module", "main"] {
3074            if let Some(target) = package_json.get(field).and_then(Value::as_str) {
3075                if let Some(path) = resolve_package_target(package_root, target) {
3076                    return Some(path);
3077                }
3078            }
3079        }
3080    }
3081
3082    resolve_package_fallback(package_root, subpath.as_deref())
3083}
3084
3085fn export_target_for_subpath(exports: &Value, subpath: Option<&str>) -> Option<String> {
3086    let key = subpath
3087        .map(|value| format!("./{value}"))
3088        .unwrap_or_else(|| ".".to_string());
3089
3090    match exports {
3091        Value::String(target) if key == "." => Some(target.clone()),
3092        Value::Object(map) => {
3093            if let Some(target) = map.get(&key).and_then(export_condition_target) {
3094                return Some(target);
3095            }
3096
3097            if let Some(target) = wildcard_export_target(map, &key) {
3098                return Some(target);
3099            }
3100
3101            if key == "." && !map.contains_key(".") && !map.keys().any(|k| k.starts_with("./")) {
3102                return export_condition_target(exports);
3103            }
3104
3105            None
3106        }
3107        _ => None,
3108    }
3109}
3110
3111fn wildcard_export_target(map: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
3112    for (pattern, target) in map {
3113        let Some(star_index) = pattern.find('*') else {
3114            continue;
3115        };
3116        let (prefix, suffix_with_star) = pattern.split_at(star_index);
3117        let suffix = &suffix_with_star[1..];
3118        if !key.starts_with(prefix) || !key.ends_with(suffix) {
3119            continue;
3120        }
3121        let matched = &key[prefix.len()..key.len() - suffix.len()];
3122        if let Some(target_pattern) = export_condition_target(target) {
3123            return Some(target_pattern.replace('*', matched));
3124        }
3125    }
3126    None
3127}
3128
3129fn export_condition_target(value: &Value) -> Option<String> {
3130    match value {
3131        Value::String(target) => Some(target.clone()),
3132        Value::Object(map) => ["source", "import", "module", "default", "types"]
3133            .into_iter()
3134            .find_map(|field| map.get(field).and_then(export_condition_target)),
3135        _ => None,
3136    }
3137}
3138
3139fn resolve_package_target(package_root: &Path, target: &str) -> Option<PathBuf> {
3140    let target = target.strip_prefix("./").unwrap_or(target);
3141    // Prefer source over compiled bundle when both exist: the callgraph
3142    // walks source files and cannot extract symbols from a built JS bundle.
3143    if let Some(src_relative) = target.strip_prefix("dist/") {
3144        if let Some(path) = resolve_file_like_path(&package_root.join("src").join(src_relative)) {
3145            return Some(path);
3146        }
3147    }
3148
3149    resolve_file_like_path(&package_root.join(target))
3150}
3151
3152fn resolve_package_fallback(package_root: &Path, subpath: Option<&str>) -> Option<PathBuf> {
3153    match subpath {
3154        Some(subpath) => resolve_file_like_path(&package_root.join(subpath))
3155            .or_else(|| resolve_file_like_path(&package_root.join("src").join(subpath))),
3156        None => resolve_file_like_path(&package_root.join("src").join("index"))
3157            .or_else(|| resolve_file_like_path(&package_root.join("index"))),
3158    }
3159}
3160
3161fn resolve_reexported_symbol<F, D>(
3162    file: &Path,
3163    symbol_name: &str,
3164    file_exports_symbol: &mut F,
3165    file_default_export_symbol: &mut D,
3166) -> Option<ResolvedSymbol>
3167where
3168    F: FnMut(&Path, &str) -> bool,
3169    D: FnMut(&Path) -> Option<String>,
3170{
3171    let mut visited = HashSet::new();
3172    resolve_reexported_symbol_inner(
3173        file,
3174        symbol_name,
3175        file_exports_symbol,
3176        file_default_export_symbol,
3177        &mut visited,
3178    )
3179}
3180
3181fn resolve_reexported_symbol_inner<F, D>(
3182    file: &Path,
3183    symbol_name: &str,
3184    file_exports_symbol: &mut F,
3185    file_default_export_symbol: &mut D,
3186    visited: &mut HashSet<(PathBuf, String)>,
3187) -> Option<ResolvedSymbol>
3188where
3189    F: FnMut(&Path, &str) -> bool,
3190    D: FnMut(&Path) -> Option<String>,
3191{
3192    let canon = std::fs::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
3193    if !visited.insert((canon.clone(), symbol_name.to_string())) {
3194        return None;
3195    }
3196
3197    let source = std::fs::read_to_string(&canon).ok()?;
3198    let lang = detect_language(&canon)?;
3199    if !matches!(lang, LangId::TypeScript | LangId::Tsx | LangId::JavaScript) {
3200        if symbol_name == "default" {
3201            return file_default_export_symbol(&canon).map(|symbol| ResolvedSymbol {
3202                file: canon,
3203                symbol,
3204            });
3205        }
3206        return file_exports_symbol(&canon, symbol_name).then(|| ResolvedSymbol {
3207            file: canon,
3208            symbol: symbol_name.to_string(),
3209        });
3210    }
3211
3212    let grammar = grammar_for(lang);
3213    let mut parser = Parser::new();
3214    parser.set_language(&grammar).ok()?;
3215    let tree = parser.parse(&source, None)?;
3216    let from_dir = canon.parent().unwrap_or_else(|| Path::new("."));
3217
3218    let mut cursor = tree.root_node().walk();
3219    if !cursor.goto_first_child() {
3220        return None;
3221    }
3222
3223    loop {
3224        let node = cursor.node();
3225        if node.kind() == "export_statement" {
3226            if let Some(target) = resolve_reexport_statement(
3227                &source,
3228                node,
3229                from_dir,
3230                symbol_name,
3231                file_exports_symbol,
3232                file_default_export_symbol,
3233                visited,
3234            ) {
3235                return Some(target);
3236            }
3237        }
3238
3239        if !cursor.goto_next_sibling() {
3240            break;
3241        }
3242    }
3243
3244    if symbol_name == "default" {
3245        if let Some(symbol) = file_default_export_symbol(&canon) {
3246            return Some(ResolvedSymbol {
3247                file: canon,
3248                symbol,
3249            });
3250        }
3251    }
3252
3253    if let Some(symbol) = resolve_local_export_alias(&source, &canon, symbol_name) {
3254        return Some(ResolvedSymbol {
3255            file: canon,
3256            symbol,
3257        });
3258    }
3259
3260    if file_exports_symbol(&canon, symbol_name) {
3261        let symbol = symbol_name.to_string();
3262        return Some(ResolvedSymbol {
3263            file: canon,
3264            symbol,
3265        });
3266    }
3267
3268    None
3269}
3270
3271fn resolve_reexport_statement<F, D>(
3272    source: &str,
3273    node: tree_sitter::Node,
3274    from_dir: &Path,
3275    symbol_name: &str,
3276    file_exports_symbol: &mut F,
3277    file_default_export_symbol: &mut D,
3278    visited: &mut HashSet<(PathBuf, String)>,
3279) -> Option<ResolvedSymbol>
3280where
3281    F: FnMut(&Path, &str) -> bool,
3282    D: FnMut(&Path) -> Option<String>,
3283{
3284    let source_node = node.child_by_field_name("source")?;
3285    let module_path = string_literal_content(source, source_node)?;
3286    let target_file = resolve_module_path(from_dir, &module_path)?;
3287    let raw_export = node_text(node, source);
3288
3289    if let Some(source_symbol) = reexport_clause_source_symbol(&raw_export, symbol_name) {
3290        return resolve_reexported_symbol_inner(
3291            &target_file,
3292            &source_symbol,
3293            file_exports_symbol,
3294            file_default_export_symbol,
3295            visited,
3296        )
3297        .or(Some(ResolvedSymbol {
3298            file: target_file,
3299            symbol: source_symbol,
3300        }));
3301    }
3302
3303    if raw_export.contains('*') {
3304        return resolve_reexported_symbol_inner(
3305            &target_file,
3306            symbol_name,
3307            file_exports_symbol,
3308            file_default_export_symbol,
3309            visited,
3310        );
3311    }
3312
3313    None
3314}
3315
3316fn resolve_local_export_alias(source: &str, file: &Path, requested_export: &str) -> Option<String> {
3317    let lang = detect_language(file)?;
3318    let grammar = grammar_for(lang);
3319    let mut parser = Parser::new();
3320    parser.set_language(&grammar).ok()?;
3321    let tree = parser.parse(source, None)?;
3322
3323    let mut cursor = tree.root_node().walk();
3324    if !cursor.goto_first_child() {
3325        return None;
3326    }
3327
3328    loop {
3329        let node = cursor.node();
3330        if node.kind() == "export_statement" && node.child_by_field_name("source").is_none() {
3331            let raw_export = node_text(node, source);
3332            if let Some(source_symbol) =
3333                reexport_clause_source_symbol(&raw_export, requested_export)
3334            {
3335                return Some(source_symbol);
3336            }
3337        }
3338
3339        if !cursor.goto_next_sibling() {
3340            break;
3341        }
3342    }
3343
3344    None
3345}
3346
3347fn reexport_clause_source_symbol(raw_export: &str, requested_export: &str) -> Option<String> {
3348    let start = raw_export.find('{')? + 1;
3349    let end = raw_export[start..].find('}')? + start;
3350    for specifier in raw_export[start..end].split(',') {
3351        let specifier = specifier.trim();
3352        if specifier.is_empty() {
3353            continue;
3354        }
3355        let specifier = specifier.strip_prefix("type ").unwrap_or(specifier).trim();
3356        if let Some((imported, exported)) = specifier.split_once(" as ") {
3357            if exported.trim() == requested_export {
3358                return Some(imported.trim().to_string());
3359            }
3360        } else if specifier == requested_export {
3361            return Some(requested_export.to_string());
3362        }
3363    }
3364    None
3365}
3366
3367fn string_literal_content(source: &str, node: tree_sitter::Node) -> Option<String> {
3368    let raw = source[node.byte_range()].trim();
3369    let quote = raw.chars().next()?;
3370    if quote != '\'' && quote != '"' {
3371        return None;
3372    }
3373    raw.strip_prefix(quote)
3374        .and_then(|value| value.strip_suffix(quote))
3375        .map(ToOwned::to_owned)
3376}
3377
3378/// Find an index file in a directory.
3379fn find_index_file(dir: &Path) -> Option<PathBuf> {
3380    let candidates = ["index.ts", "index.tsx", "index.js", "index.jsx"];
3381    for name in &candidates {
3382        let p = dir.join(name);
3383        if p.is_file() {
3384            return Some(std::fs::canonicalize(&p).unwrap_or(p));
3385        }
3386    }
3387    None
3388}
3389
3390/// Resolve an aliased import: `import { foo as bar } from './utils'`
3391/// where `local_name` is "bar". Returns `(original_name, resolved_file_path)`.
3392fn resolve_aliased_import(
3393    local_name: &str,
3394    import_block: &ImportBlock,
3395    caller_dir: &Path,
3396) -> Option<(String, PathBuf)> {
3397    for imp in &import_block.imports {
3398        // Parse the raw text to find "as <alias>" patterns
3399        // This handles: import { foo as bar, baz as qux } from './mod'
3400        if let Some(original) = find_alias_original(&imp.raw_text, local_name) {
3401            if let Some(resolved_path) = resolve_module_path(caller_dir, &imp.module_path) {
3402                return Some((original, resolved_path));
3403            }
3404        }
3405    }
3406    None
3407}
3408
3409/// Parse import raw text to find the original name for an alias.
3410/// Given raw text like `import { foo as bar, baz } from './utils'` and
3411/// local_name "bar", returns Some("foo").
3412fn find_alias_original(raw_import: &str, local_name: &str) -> Option<String> {
3413    // Look for pattern: <original> as <alias>
3414    // This is a simple text-based search; handles the common TS/JS pattern
3415    let search = format!(" as {}", local_name);
3416    if let Some(pos) = raw_import.find(&search) {
3417        // Walk backwards from `pos` to find the original name
3418        let before = &raw_import[..pos];
3419        // The original name is the last word-like token before " as "
3420        let original = before
3421            .rsplit(|c: char| c == '{' || c == ',' || c.is_whitespace())
3422            .find(|s| !s.is_empty())?;
3423        return Some(original.to_string());
3424    }
3425    None
3426}
3427
3428// ---------------------------------------------------------------------------
3429// Worktree file discovery
3430// ---------------------------------------------------------------------------
3431
3432/// Walk project files respecting .gitignore, excluding common non-source dirs.
3433///
3434/// Returns an iterator of file paths for supported source file types.
3435pub fn walk_project_files(root: &Path) -> impl Iterator<Item = PathBuf> {
3436    use ignore::WalkBuilder;
3437
3438    let walker = WalkBuilder::new(root)
3439        .hidden(true)         // skip hidden files/dirs
3440        .git_ignore(true)     // respect .gitignore
3441        .git_global(true)     // respect global gitignore
3442        .git_exclude(true)    // respect .git/info/exclude
3443        .filter_entry(|entry| {
3444            let name = entry.file_name().to_string_lossy();
3445            // Always exclude these directories regardless of .gitignore
3446            if entry.file_type().map_or(false, |ft| ft.is_dir()) {
3447                return !matches!(
3448                    name.as_ref(),
3449                    "node_modules" | "target" | "venv" | ".venv" | ".git" | "__pycache__"
3450                        | ".tox" | "dist" | "build"
3451                );
3452            }
3453            true
3454        })
3455        .build();
3456
3457    walker
3458        .filter_map(|entry| entry.ok())
3459        .filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
3460        .filter(|entry| detect_language(entry.path()).is_some())
3461        .map(|entry| entry.into_path())
3462}
3463
3464// ---------------------------------------------------------------------------
3465// Tests
3466// ---------------------------------------------------------------------------
3467
3468#[cfg(test)]
3469mod tests {
3470    use super::*;
3471    use std::fs;
3472    use tempfile::TempDir;
3473
3474    /// Create a temp directory with TypeScript files for testing.
3475    fn setup_ts_project() -> TempDir {
3476        let dir = TempDir::new().unwrap();
3477
3478        // main.ts: imports from utils and calls functions
3479        fs::write(
3480            dir.path().join("main.ts"),
3481            r#"import { helper, compute } from './utils';
3482import * as math from './math';
3483
3484export function main() {
3485    const a = helper(1);
3486    const b = compute(a, 2);
3487    const c = math.add(a, b);
3488    return c;
3489}
3490"#,
3491        )
3492        .unwrap();
3493
3494        // utils.ts: defines helper and compute, imports from helpers
3495        fs::write(
3496            dir.path().join("utils.ts"),
3497            r#"import { double } from './helpers';
3498
3499export function helper(x: number): number {
3500    return double(x);
3501}
3502
3503export function compute(a: number, b: number): number {
3504    return a + b;
3505}
3506"#,
3507        )
3508        .unwrap();
3509
3510        // helpers.ts: defines double
3511        fs::write(
3512            dir.path().join("helpers.ts"),
3513            r#"export function double(x: number): number {
3514    return x * 2;
3515}
3516
3517export function triple(x: number): number {
3518    return x * 3;
3519}
3520"#,
3521        )
3522        .unwrap();
3523
3524        // math.ts: defines add (for namespace import test)
3525        fs::write(
3526            dir.path().join("math.ts"),
3527            r#"export function add(a: number, b: number): number {
3528    return a + b;
3529}
3530
3531export function subtract(a: number, b: number): number {
3532    return a - b;
3533}
3534"#,
3535        )
3536        .unwrap();
3537
3538        dir
3539    }
3540
3541    /// Create a project with import aliasing.
3542    fn setup_alias_project() -> TempDir {
3543        let dir = TempDir::new().unwrap();
3544
3545        fs::write(
3546            dir.path().join("main.ts"),
3547            r#"import { helper as h } from './utils';
3548
3549export function main() {
3550    return h(42);
3551}
3552"#,
3553        )
3554        .unwrap();
3555
3556        fs::write(
3557            dir.path().join("utils.ts"),
3558            r#"export function helper(x: number): number {
3559    return x + 1;
3560}
3561"#,
3562        )
3563        .unwrap();
3564
3565        dir
3566    }
3567
3568    /// Create a project with a cycle: A → B → A.
3569    fn setup_cycle_project() -> TempDir {
3570        let dir = TempDir::new().unwrap();
3571
3572        fs::write(
3573            dir.path().join("a.ts"),
3574            r#"import { funcB } from './b';
3575
3576export function funcA() {
3577    return funcB();
3578}
3579"#,
3580        )
3581        .unwrap();
3582
3583        fs::write(
3584            dir.path().join("b.ts"),
3585            r#"import { funcA } from './a';
3586
3587export function funcB() {
3588    return funcA();
3589}
3590"#,
3591        )
3592        .unwrap();
3593
3594        dir
3595    }
3596
3597    // --- Single-file call extraction ---
3598
3599    #[test]
3600    fn callgraph_single_file_call_extraction() {
3601        let dir = setup_ts_project();
3602        let mut graph = CallGraph::new(dir.path().to_path_buf());
3603
3604        let file_data = graph.build_file(&dir.path().join("main.ts")).unwrap();
3605        let main_calls = &file_data.calls_by_symbol["main"];
3606
3607        let callee_names: Vec<&str> = main_calls.iter().map(|c| c.callee_name.as_str()).collect();
3608        assert!(
3609            callee_names.contains(&"helper"),
3610            "main should call helper, got: {:?}",
3611            callee_names
3612        );
3613        assert!(
3614            callee_names.contains(&"compute"),
3615            "main should call compute, got: {:?}",
3616            callee_names
3617        );
3618        assert!(
3619            callee_names.contains(&"add"),
3620            "main should call math.add (short name: add), got: {:?}",
3621            callee_names
3622        );
3623    }
3624
3625    #[test]
3626    fn callgraph_file_data_has_exports() {
3627        let dir = setup_ts_project();
3628        let mut graph = CallGraph::new(dir.path().to_path_buf());
3629
3630        let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
3631        assert!(
3632            file_data.exported_symbols.contains(&"helper".to_string()),
3633            "utils.ts should export helper, got: {:?}",
3634            file_data.exported_symbols
3635        );
3636        assert!(
3637            file_data.exported_symbols.contains(&"compute".to_string()),
3638            "utils.ts should export compute, got: {:?}",
3639            file_data.exported_symbols
3640        );
3641    }
3642
3643    // --- Cross-file resolution ---
3644
3645    #[test]
3646    fn callgraph_resolve_direct_import() {
3647        let dir = setup_ts_project();
3648        let mut graph = CallGraph::new(dir.path().to_path_buf());
3649
3650        let main_path = dir.path().join("main.ts");
3651        let file_data = graph.build_file(&main_path).unwrap();
3652        let import_block = file_data.import_block.clone();
3653
3654        let edge = graph.resolve_cross_file_edge("helper", "helper", &main_path, &import_block);
3655        match edge {
3656            EdgeResolution::Resolved { file, symbol } => {
3657                assert!(
3658                    file.ends_with("utils.ts"),
3659                    "helper should resolve to utils.ts, got: {:?}",
3660                    file
3661                );
3662                assert_eq!(symbol, "helper");
3663            }
3664            EdgeResolution::Unresolved { callee_name } => {
3665                panic!("Expected resolved, got unresolved: {}", callee_name);
3666            }
3667        }
3668    }
3669
3670    #[test]
3671    fn callgraph_resolve_namespace_import() {
3672        let dir = setup_ts_project();
3673        let mut graph = CallGraph::new(dir.path().to_path_buf());
3674
3675        let main_path = dir.path().join("main.ts");
3676        let file_data = graph.build_file(&main_path).unwrap();
3677        let import_block = file_data.import_block.clone();
3678
3679        let edge = graph.resolve_cross_file_edge("math.add", "add", &main_path, &import_block);
3680        match edge {
3681            EdgeResolution::Resolved { file, symbol } => {
3682                assert!(
3683                    file.ends_with("math.ts"),
3684                    "math.add should resolve to math.ts, got: {:?}",
3685                    file
3686                );
3687                assert_eq!(symbol, "add");
3688            }
3689            EdgeResolution::Unresolved { callee_name } => {
3690                panic!("Expected resolved, got unresolved: {}", callee_name);
3691            }
3692        }
3693    }
3694
3695    #[test]
3696    fn callgraph_resolve_aliased_import() {
3697        let dir = setup_alias_project();
3698        let mut graph = CallGraph::new(dir.path().to_path_buf());
3699
3700        let main_path = dir.path().join("main.ts");
3701        let file_data = graph.build_file(&main_path).unwrap();
3702        let import_block = file_data.import_block.clone();
3703
3704        let edge = graph.resolve_cross_file_edge("h", "h", &main_path, &import_block);
3705        match edge {
3706            EdgeResolution::Resolved { file, symbol } => {
3707                assert!(
3708                    file.ends_with("utils.ts"),
3709                    "h (alias for helper) should resolve to utils.ts, got: {:?}",
3710                    file
3711                );
3712                assert_eq!(symbol, "helper");
3713            }
3714            EdgeResolution::Unresolved { callee_name } => {
3715                panic!("Expected resolved, got unresolved: {}", callee_name);
3716            }
3717        }
3718    }
3719
3720    #[test]
3721    fn callgraph_unresolved_edge_marked() {
3722        let dir = setup_ts_project();
3723        let mut graph = CallGraph::new(dir.path().to_path_buf());
3724
3725        let main_path = dir.path().join("main.ts");
3726        let file_data = graph.build_file(&main_path).unwrap();
3727        let import_block = file_data.import_block.clone();
3728
3729        let edge =
3730            graph.resolve_cross_file_edge("unknownFunc", "unknownFunc", &main_path, &import_block);
3731        assert_eq!(
3732            edge,
3733            EdgeResolution::Unresolved {
3734                callee_name: "unknownFunc".to_string()
3735            },
3736            "Unknown callee should be unresolved"
3737        );
3738    }
3739
3740    // --- Cycle detection ---
3741
3742    #[test]
3743    fn callgraph_cycle_detection_stops() {
3744        let dir = setup_cycle_project();
3745        let mut graph = CallGraph::new(dir.path().to_path_buf());
3746
3747        // This should NOT infinite loop
3748        let tree = graph
3749            .forward_tree(&dir.path().join("a.ts"), "funcA", 10)
3750            .unwrap();
3751
3752        assert_eq!(tree.name, "funcA");
3753        assert!(tree.resolved);
3754
3755        // funcA calls funcB, funcB calls funcA (cycle), so the depth should be bounded
3756        // The tree should have children but not infinitely deep
3757        fn count_depth(node: &CallTreeNode) -> usize {
3758            if node.children.is_empty() {
3759                1
3760            } else {
3761                1 + node.children.iter().map(count_depth).max().unwrap_or(0)
3762            }
3763        }
3764
3765        let depth = count_depth(&tree);
3766        assert!(
3767            depth <= 4,
3768            "Cycle should be detected and bounded, depth was: {}",
3769            depth
3770        );
3771    }
3772
3773    // --- Depth limiting ---
3774
3775    #[test]
3776    fn callgraph_depth_limit_truncates() {
3777        let dir = setup_ts_project();
3778        let mut graph = CallGraph::new(dir.path().to_path_buf());
3779
3780        // main → helper → double, main → compute
3781        // With depth 1, we should see direct callees but not their children
3782        let tree = graph
3783            .forward_tree(&dir.path().join("main.ts"), "main", 1)
3784            .unwrap();
3785
3786        assert_eq!(tree.name, "main");
3787
3788        // At depth 1, children should exist (direct calls) but their children should be empty
3789        for child in &tree.children {
3790            assert!(
3791                child.children.is_empty(),
3792                "At depth 1, child '{}' should have no children, got {:?}",
3793                child.name,
3794                child.children.len()
3795            );
3796        }
3797    }
3798
3799    #[test]
3800    fn callgraph_depth_zero_no_children() {
3801        let dir = setup_ts_project();
3802        let mut graph = CallGraph::new(dir.path().to_path_buf());
3803
3804        let tree = graph
3805            .forward_tree(&dir.path().join("main.ts"), "main", 0)
3806            .unwrap();
3807
3808        assert_eq!(tree.name, "main");
3809        assert!(
3810            tree.children.is_empty(),
3811            "At depth 0, should have no children"
3812        );
3813    }
3814
3815    // --- Forward tree cross-file ---
3816
3817    #[test]
3818    fn callgraph_forward_tree_cross_file() {
3819        let dir = setup_ts_project();
3820        let mut graph = CallGraph::new(dir.path().to_path_buf());
3821
3822        // main → helper (in utils.ts) → double (in helpers.ts)
3823        let tree = graph
3824            .forward_tree(&dir.path().join("main.ts"), "main", 5)
3825            .unwrap();
3826
3827        assert_eq!(tree.name, "main");
3828        assert!(tree.resolved);
3829
3830        // Find the helper child
3831        let helper_child = tree.children.iter().find(|c| c.name == "helper");
3832        assert!(
3833            helper_child.is_some(),
3834            "main should have helper as child, children: {:?}",
3835            tree.children.iter().map(|c| &c.name).collect::<Vec<_>>()
3836        );
3837
3838        let helper = helper_child.unwrap();
3839        assert!(
3840            helper.file.ends_with("utils.ts") || helper.file == "utils.ts",
3841            "helper should be in utils.ts, got: {}",
3842            helper.file
3843        );
3844
3845        // helper should call double (in helpers.ts)
3846        let double_child = helper.children.iter().find(|c| c.name == "double");
3847        assert!(
3848            double_child.is_some(),
3849            "helper should call double, children: {:?}",
3850            helper.children.iter().map(|c| &c.name).collect::<Vec<_>>()
3851        );
3852
3853        let double = double_child.unwrap();
3854        assert!(
3855            double.file.ends_with("helpers.ts") || double.file == "helpers.ts",
3856            "double should be in helpers.ts, got: {}",
3857            double.file
3858        );
3859    }
3860
3861    // --- Worktree walker ---
3862
3863    #[test]
3864    fn callgraph_walker_excludes_gitignored() {
3865        let dir = TempDir::new().unwrap();
3866
3867        // Create a .gitignore
3868        fs::write(dir.path().join(".gitignore"), "ignored_dir/\n").unwrap();
3869
3870        // Create files
3871        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3872        fs::create_dir(dir.path().join("ignored_dir")).unwrap();
3873        fs::write(
3874            dir.path().join("ignored_dir").join("secret.ts"),
3875            "export function secret() {}",
3876        )
3877        .unwrap();
3878
3879        // Also create node_modules (should always be excluded)
3880        fs::create_dir(dir.path().join("node_modules")).unwrap();
3881        fs::write(
3882            dir.path().join("node_modules").join("dep.ts"),
3883            "export function dep() {}",
3884        )
3885        .unwrap();
3886
3887        // Init git repo for .gitignore to work
3888        std::process::Command::new("git")
3889            .args(["init"])
3890            .current_dir(dir.path())
3891            .output()
3892            .unwrap();
3893
3894        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3895        let file_names: Vec<String> = files
3896            .iter()
3897            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3898            .collect();
3899
3900        assert!(
3901            file_names.contains(&"main.ts".to_string()),
3902            "Should include main.ts, got: {:?}",
3903            file_names
3904        );
3905        assert!(
3906            !file_names.contains(&"secret.ts".to_string()),
3907            "Should exclude gitignored secret.ts, got: {:?}",
3908            file_names
3909        );
3910        assert!(
3911            !file_names.contains(&"dep.ts".to_string()),
3912            "Should exclude node_modules, got: {:?}",
3913            file_names
3914        );
3915    }
3916
3917    #[test]
3918    fn callgraph_walker_only_source_files() {
3919        let dir = TempDir::new().unwrap();
3920
3921        fs::write(dir.path().join("main.ts"), "export function main() {}").unwrap();
3922        fs::write(dir.path().join("readme.md"), "# Hello").unwrap();
3923        fs::write(dir.path().join("data.json"), "{}").unwrap();
3924
3925        let files: Vec<PathBuf> = walk_project_files(dir.path()).collect();
3926        let file_names: Vec<String> = files
3927            .iter()
3928            .map(|f| f.file_name().unwrap().to_string_lossy().to_string())
3929            .collect();
3930
3931        assert!(file_names.contains(&"main.ts".to_string()));
3932        assert!(
3933            file_names.contains(&"readme.md".to_string()),
3934            "Markdown is now a supported source language"
3935        );
3936        assert!(
3937            file_names.contains(&"data.json".to_string()),
3938            "JSON is now a supported source language"
3939        );
3940    }
3941
3942    // --- find_alias_original ---
3943
3944    #[test]
3945    fn callgraph_find_alias_original_simple() {
3946        let raw = "import { foo as bar } from './utils';";
3947        assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
3948    }
3949
3950    #[test]
3951    fn callgraph_find_alias_original_multiple() {
3952        let raw = "import { foo as bar, baz as qux } from './utils';";
3953        assert_eq!(find_alias_original(raw, "bar"), Some("foo".to_string()));
3954        assert_eq!(find_alias_original(raw, "qux"), Some("baz".to_string()));
3955    }
3956
3957    #[test]
3958    fn callgraph_find_alias_no_match() {
3959        let raw = "import { foo } from './utils';";
3960        assert_eq!(find_alias_original(raw, "foo"), None);
3961    }
3962
3963    // --- Reverse callers ---
3964
3965    #[test]
3966    fn callgraph_callers_of_direct() {
3967        let dir = setup_ts_project();
3968        let mut graph = CallGraph::new(dir.path().to_path_buf());
3969
3970        // helpers.ts:double is called by utils.ts:helper
3971        let result = graph
3972            .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
3973            .unwrap();
3974
3975        assert_eq!(result.symbol, "double");
3976        assert!(result.total_callers > 0, "double should have callers");
3977        assert!(result.scanned_files > 0, "should have scanned files");
3978
3979        // Find the caller from utils.ts
3980        let utils_group = result.callers.iter().find(|g| g.file.contains("utils.ts"));
3981        assert!(
3982            utils_group.is_some(),
3983            "double should be called from utils.ts, groups: {:?}",
3984            result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
3985        );
3986
3987        let group = utils_group.unwrap();
3988        let helper_caller = group.callers.iter().find(|c| c.symbol == "helper");
3989        assert!(
3990            helper_caller.is_some(),
3991            "double should be called by helper, callers: {:?}",
3992            group.callers.iter().map(|c| &c.symbol).collect::<Vec<_>>()
3993        );
3994    }
3995
3996    #[test]
3997    fn callgraph_callers_of_no_callers() {
3998        let dir = setup_ts_project();
3999        let mut graph = CallGraph::new(dir.path().to_path_buf());
4000
4001        // main.ts:main is the entry point — nothing calls it
4002        let result = graph
4003            .callers_of(&dir.path().join("main.ts"), "main", 1, usize::MAX)
4004            .unwrap();
4005
4006        assert_eq!(result.symbol, "main");
4007        assert_eq!(result.total_callers, 0, "main should have no callers");
4008        assert!(result.callers.is_empty());
4009    }
4010
4011    #[test]
4012    fn callgraph_callers_recursive_depth() {
4013        let dir = setup_ts_project();
4014        let mut graph = CallGraph::new(dir.path().to_path_buf());
4015
4016        // helpers.ts:double is called by utils.ts:helper
4017        // utils.ts:helper is called by main.ts:main
4018        // With depth=2, we should see both direct and transitive callers
4019        let result = graph
4020            .callers_of(&dir.path().join("helpers.ts"), "double", 2, usize::MAX)
4021            .unwrap();
4022
4023        assert!(
4024            result.total_callers >= 2,
4025            "with depth 2, double should have >= 2 callers (direct + transitive), got {}",
4026            result.total_callers
4027        );
4028
4029        // Should include caller from main.ts (transitive: main → helper → double)
4030        let main_group = result.callers.iter().find(|g| g.file.contains("main.ts"));
4031        assert!(
4032            main_group.is_some(),
4033            "recursive callers should include main.ts, groups: {:?}",
4034            result.callers.iter().map(|g| &g.file).collect::<Vec<_>>()
4035        );
4036    }
4037
4038    #[test]
4039    fn callgraph_invalidate_file_clears_reverse_index() {
4040        let dir = setup_ts_project();
4041        let mut graph = CallGraph::new(dir.path().to_path_buf());
4042
4043        // Build callers to populate the reverse index
4044        let _ = graph
4045            .callers_of(&dir.path().join("helpers.ts"), "double", 1, usize::MAX)
4046            .unwrap();
4047        assert!(
4048            graph.reverse_index.is_some(),
4049            "reverse index should be built"
4050        );
4051
4052        // Invalidate a file
4053        graph.invalidate_file(&dir.path().join("utils.ts"));
4054
4055        // Reverse index should be cleared
4056        assert!(
4057            graph.reverse_index.is_none(),
4058            "invalidate_file should clear reverse index"
4059        );
4060        // Data cache for the file should be cleared
4061        let canon = std::fs::canonicalize(dir.path().join("utils.ts")).unwrap();
4062        assert!(
4063            !graph.data.contains_key(&canon),
4064            "invalidate_file should remove file from data cache"
4065        );
4066        // Project files should be cleared
4067        assert!(
4068            graph.project_files.is_none(),
4069            "invalidate_file should clear project_files"
4070        );
4071    }
4072
4073    // --- is_entry_point ---
4074
4075    #[test]
4076    fn is_entry_point_exported_function() {
4077        assert!(is_entry_point(
4078            "handleRequest",
4079            &SymbolKind::Function,
4080            true,
4081            LangId::TypeScript
4082        ));
4083    }
4084
4085    #[test]
4086    fn is_entry_point_exported_method_is_not_entry() {
4087        // Methods are class members, not standalone entry points
4088        assert!(!is_entry_point(
4089            "handleRequest",
4090            &SymbolKind::Method,
4091            true,
4092            LangId::TypeScript
4093        ));
4094    }
4095
4096    #[test]
4097    fn is_entry_point_main_init_patterns() {
4098        for name in &["main", "Main", "MAIN", "init", "setup", "bootstrap", "run"] {
4099            assert!(
4100                is_entry_point(name, &SymbolKind::Function, false, LangId::TypeScript),
4101                "{} should be an entry point",
4102                name
4103            );
4104        }
4105    }
4106
4107    #[test]
4108    fn is_entry_point_test_patterns_ts() {
4109        assert!(is_entry_point(
4110            "describe",
4111            &SymbolKind::Function,
4112            false,
4113            LangId::TypeScript
4114        ));
4115        assert!(is_entry_point(
4116            "it",
4117            &SymbolKind::Function,
4118            false,
4119            LangId::TypeScript
4120        ));
4121        assert!(is_entry_point(
4122            "test",
4123            &SymbolKind::Function,
4124            false,
4125            LangId::TypeScript
4126        ));
4127        assert!(is_entry_point(
4128            "testValidation",
4129            &SymbolKind::Function,
4130            false,
4131            LangId::TypeScript
4132        ));
4133        assert!(is_entry_point(
4134            "specHelper",
4135            &SymbolKind::Function,
4136            false,
4137            LangId::TypeScript
4138        ));
4139    }
4140
4141    #[test]
4142    fn is_entry_point_test_patterns_python() {
4143        assert!(is_entry_point(
4144            "test_login",
4145            &SymbolKind::Function,
4146            false,
4147            LangId::Python
4148        ));
4149        assert!(is_entry_point(
4150            "setUp",
4151            &SymbolKind::Function,
4152            false,
4153            LangId::Python
4154        ));
4155        assert!(is_entry_point(
4156            "tearDown",
4157            &SymbolKind::Function,
4158            false,
4159            LangId::Python
4160        ));
4161        // "testSomething" should NOT match Python (needs test_ prefix)
4162        assert!(!is_entry_point(
4163            "testSomething",
4164            &SymbolKind::Function,
4165            false,
4166            LangId::Python
4167        ));
4168    }
4169
4170    #[test]
4171    fn is_entry_point_test_patterns_rust() {
4172        assert!(is_entry_point(
4173            "test_parse",
4174            &SymbolKind::Function,
4175            false,
4176            LangId::Rust
4177        ));
4178        assert!(!is_entry_point(
4179            "TestSomething",
4180            &SymbolKind::Function,
4181            false,
4182            LangId::Rust
4183        ));
4184    }
4185
4186    #[test]
4187    fn is_entry_point_test_patterns_go() {
4188        assert!(is_entry_point(
4189            "TestParsing",
4190            &SymbolKind::Function,
4191            false,
4192            LangId::Go
4193        ));
4194        // lowercase test should NOT match Go (needs uppercase Test prefix)
4195        assert!(!is_entry_point(
4196            "testParsing",
4197            &SymbolKind::Function,
4198            false,
4199            LangId::Go
4200        ));
4201    }
4202
4203    #[test]
4204    fn is_entry_point_non_exported_non_main_is_not_entry() {
4205        assert!(!is_entry_point(
4206            "helperUtil",
4207            &SymbolKind::Function,
4208            false,
4209            LangId::TypeScript
4210        ));
4211    }
4212
4213    // --- symbol_metadata ---
4214
4215    #[test]
4216    fn callgraph_symbol_metadata_populated() {
4217        let dir = setup_ts_project();
4218        let mut graph = CallGraph::new(dir.path().to_path_buf());
4219
4220        let file_data = graph.build_file(&dir.path().join("utils.ts")).unwrap();
4221        assert!(
4222            file_data.symbol_metadata.contains_key("helper"),
4223            "symbol_metadata should contain helper"
4224        );
4225        let meta = &file_data.symbol_metadata["helper"];
4226        assert_eq!(meta.kind, SymbolKind::Function);
4227        assert!(meta.exported, "helper should be exported");
4228    }
4229
4230    // --- trace_to ---
4231
4232    /// Setup a multi-path project for trace_to tests.
4233    ///
4234    /// Structure:
4235    ///   main.ts: exported main() → processData (from utils)
4236    ///   service.ts: exported handleRequest() → processData (from utils)
4237    ///   utils.ts: exported processData() → validate (from helpers)
4238    ///   helpers.ts: exported validate() → checkFormat (local, not exported)
4239    ///   test_helpers.ts: testValidation() → validate (from helpers)
4240    ///
4241    /// checkFormat should have 3 paths:
4242    ///   main → processData → validate → checkFormat
4243    ///   handleRequest → processData → validate → checkFormat
4244    ///   testValidation → validate → checkFormat
4245    fn setup_trace_project() -> TempDir {
4246        let dir = TempDir::new().unwrap();
4247
4248        fs::write(
4249            dir.path().join("main.ts"),
4250            r#"import { processData } from './utils';
4251
4252export function main() {
4253    const result = processData("hello");
4254    return result;
4255}
4256"#,
4257        )
4258        .unwrap();
4259
4260        fs::write(
4261            dir.path().join("service.ts"),
4262            r#"import { processData } from './utils';
4263
4264export function handleRequest(input: string): string {
4265    return processData(input);
4266}
4267"#,
4268        )
4269        .unwrap();
4270
4271        fs::write(
4272            dir.path().join("utils.ts"),
4273            r#"import { validate } from './helpers';
4274
4275export function processData(input: string): string {
4276    const valid = validate(input);
4277    if (!valid) {
4278        throw new Error("invalid input");
4279    }
4280    return input.toUpperCase();
4281}
4282"#,
4283        )
4284        .unwrap();
4285
4286        fs::write(
4287            dir.path().join("helpers.ts"),
4288            r#"export function validate(input: string): boolean {
4289    return checkFormat(input);
4290}
4291
4292function checkFormat(input: string): boolean {
4293    return input.length > 0 && /^[a-zA-Z]+$/.test(input);
4294}
4295"#,
4296        )
4297        .unwrap();
4298
4299        fs::write(
4300            dir.path().join("test_helpers.ts"),
4301            r#"import { validate } from './helpers';
4302
4303function testValidation() {
4304    const result = validate("hello");
4305    console.log(result);
4306}
4307"#,
4308        )
4309        .unwrap();
4310
4311        // git init so the walker works
4312        std::process::Command::new("git")
4313            .args(["init"])
4314            .current_dir(dir.path())
4315            .output()
4316            .unwrap();
4317
4318        dir
4319    }
4320
4321    #[test]
4322    fn trace_to_multi_path() {
4323        let dir = setup_trace_project();
4324        let mut graph = CallGraph::new(dir.path().to_path_buf());
4325
4326        let result = graph
4327            .trace_to(
4328                &dir.path().join("helpers.ts"),
4329                "checkFormat",
4330                10,
4331                usize::MAX,
4332            )
4333            .unwrap();
4334
4335        assert_eq!(result.target_symbol, "checkFormat");
4336        assert!(
4337            result.total_paths >= 2,
4338            "checkFormat should have at least 2 paths, got {} (paths: {:?})",
4339            result.total_paths,
4340            result
4341                .paths
4342                .iter()
4343                .map(|p| p.hops.iter().map(|h| h.symbol.as_str()).collect::<Vec<_>>())
4344                .collect::<Vec<_>>()
4345        );
4346
4347        // Check that paths are top-down: entry point first, target last
4348        for path in &result.paths {
4349            assert!(
4350                path.hops.first().unwrap().is_entry_point,
4351                "First hop should be an entry point, got: {}",
4352                path.hops.first().unwrap().symbol
4353            );
4354            assert_eq!(
4355                path.hops.last().unwrap().symbol,
4356                "checkFormat",
4357                "Last hop should be checkFormat"
4358            );
4359        }
4360
4361        // Verify entry_points_found > 0
4362        assert!(
4363            result.entry_points_found >= 2,
4364            "should find at least 2 entry points, got {}",
4365            result.entry_points_found
4366        );
4367    }
4368
4369    #[test]
4370    fn trace_to_single_path() {
4371        let dir = setup_trace_project();
4372        let mut graph = CallGraph::new(dir.path().to_path_buf());
4373
4374        // validate is called from processData, testValidation
4375        // processData is called from main, handleRequest
4376        // So validate has paths: main→processData→validate, handleRequest→processData→validate, testValidation→validate
4377        let result = graph
4378            .trace_to(&dir.path().join("helpers.ts"), "validate", 10, usize::MAX)
4379            .unwrap();
4380
4381        assert_eq!(result.target_symbol, "validate");
4382        assert!(
4383            result.total_paths >= 2,
4384            "validate should have at least 2 paths, got {}",
4385            result.total_paths
4386        );
4387    }
4388
4389    #[test]
4390    fn trace_to_cycle_detection() {
4391        let dir = setup_cycle_project();
4392        let mut graph = CallGraph::new(dir.path().to_path_buf());
4393
4394        // funcA ↔ funcB cycle — should terminate
4395        let result = graph
4396            .trace_to(&dir.path().join("a.ts"), "funcA", 10, usize::MAX)
4397            .unwrap();
4398
4399        // Should not hang — the fact we got here means cycle detection works
4400        assert_eq!(result.target_symbol, "funcA");
4401    }
4402
4403    #[test]
4404    fn trace_to_depth_limit() {
4405        let dir = setup_trace_project();
4406        let mut graph = CallGraph::new(dir.path().to_path_buf());
4407
4408        // With max_depth=1, should not be able to reach entry points that are 3+ hops away
4409        let result = graph
4410            .trace_to(&dir.path().join("helpers.ts"), "checkFormat", 1, usize::MAX)
4411            .unwrap();
4412
4413        // testValidation→validate→checkFormat is 2 hops, which requires depth >= 2
4414        // main→processData→validate→checkFormat is 3 hops, which requires depth >= 3
4415        // With depth=1, most paths should be truncated
4416        assert_eq!(result.target_symbol, "checkFormat");
4417
4418        // The shallow result should have fewer paths than the deep one
4419        let deep_result = graph
4420            .trace_to(
4421                &dir.path().join("helpers.ts"),
4422                "checkFormat",
4423                10,
4424                usize::MAX,
4425            )
4426            .unwrap();
4427
4428        assert!(
4429            result.total_paths <= deep_result.total_paths,
4430            "shallow trace should find <= paths compared to deep: {} vs {}",
4431            result.total_paths,
4432            deep_result.total_paths
4433        );
4434    }
4435
4436    #[test]
4437    fn trace_to_entry_point_target() {
4438        let dir = setup_trace_project();
4439        let mut graph = CallGraph::new(dir.path().to_path_buf());
4440
4441        // main is itself an entry point — should return a single trivial path
4442        let result = graph
4443            .trace_to(&dir.path().join("main.ts"), "main", 10, usize::MAX)
4444            .unwrap();
4445
4446        assert_eq!(result.target_symbol, "main");
4447        assert!(
4448            result.total_paths >= 1,
4449            "main should have at least 1 path (itself), got {}",
4450            result.total_paths
4451        );
4452        // Check the trivial path has just one hop
4453        let trivial = result.paths.iter().find(|p| p.hops.len() == 1);
4454        assert!(
4455            trivial.is_some(),
4456            "should have a trivial path with just the entry point itself"
4457        );
4458    }
4459
4460    // --- extract_parameters ---
4461
4462    #[test]
4463    fn extract_parameters_typescript() {
4464        let params = extract_parameters(
4465            "function processData(input: string, count: number): void",
4466            LangId::TypeScript,
4467        );
4468        assert_eq!(params, vec!["input", "count"]);
4469    }
4470
4471    #[test]
4472    fn extract_parameters_typescript_optional() {
4473        let params = extract_parameters(
4474            "function fetch(url: string, options?: RequestInit): Promise<Response>",
4475            LangId::TypeScript,
4476        );
4477        assert_eq!(params, vec!["url", "options"]);
4478    }
4479
4480    #[test]
4481    fn extract_parameters_typescript_defaults() {
4482        let params = extract_parameters(
4483            "function greet(name: string, greeting: string = \"hello\"): string",
4484            LangId::TypeScript,
4485        );
4486        assert_eq!(params, vec!["name", "greeting"]);
4487    }
4488
4489    #[test]
4490    fn extract_parameters_typescript_rest() {
4491        let params = extract_parameters(
4492            "function sum(...numbers: number[]): number",
4493            LangId::TypeScript,
4494        );
4495        assert_eq!(params, vec!["numbers"]);
4496    }
4497
4498    #[test]
4499    fn extract_parameters_python_self_skipped() {
4500        let params = extract_parameters(
4501            "def process(self, data: str, count: int) -> bool",
4502            LangId::Python,
4503        );
4504        assert_eq!(params, vec!["data", "count"]);
4505    }
4506
4507    #[test]
4508    fn extract_parameters_python_no_self() {
4509        let params = extract_parameters("def validate(input: str) -> bool", LangId::Python);
4510        assert_eq!(params, vec!["input"]);
4511    }
4512
4513    #[test]
4514    fn extract_parameters_python_star_args() {
4515        let params = extract_parameters("def func(*args, **kwargs)", LangId::Python);
4516        assert_eq!(params, vec!["args", "kwargs"]);
4517    }
4518
4519    #[test]
4520    fn extract_parameters_rust_self_skipped() {
4521        let params = extract_parameters(
4522            "fn process(&self, data: &str, count: usize) -> bool",
4523            LangId::Rust,
4524        );
4525        assert_eq!(params, vec!["data", "count"]);
4526    }
4527
4528    #[test]
4529    fn extract_parameters_rust_mut_self_skipped() {
4530        let params = extract_parameters("fn update(&mut self, value: i32)", LangId::Rust);
4531        assert_eq!(params, vec!["value"]);
4532    }
4533
4534    #[test]
4535    fn extract_parameters_rust_no_self() {
4536        let params = extract_parameters("fn validate(input: &str) -> bool", LangId::Rust);
4537        assert_eq!(params, vec!["input"]);
4538    }
4539
4540    #[test]
4541    fn extract_parameters_rust_mut_param() {
4542        let params = extract_parameters("fn process(mut buf: Vec<u8>, len: usize)", LangId::Rust);
4543        assert_eq!(params, vec!["buf", "len"]);
4544    }
4545
4546    #[test]
4547    fn extract_parameters_go() {
4548        let params = extract_parameters(
4549            "func ProcessData(input string, count int) error",
4550            LangId::Go,
4551        );
4552        assert_eq!(params, vec!["input", "count"]);
4553    }
4554
4555    #[test]
4556    fn extract_parameters_empty() {
4557        let params = extract_parameters("function noArgs(): void", LangId::TypeScript);
4558        assert!(
4559            params.is_empty(),
4560            "no-arg function should return empty params"
4561        );
4562    }
4563
4564    #[test]
4565    fn extract_parameters_no_parens() {
4566        let params = extract_parameters("const x = 42", LangId::TypeScript);
4567        assert!(params.is_empty(), "no parens should return empty params");
4568    }
4569
4570    #[test]
4571    fn extract_parameters_javascript() {
4572        let params = extract_parameters("function handleClick(event, target)", LangId::JavaScript);
4573        assert_eq!(params, vec!["event", "target"]);
4574    }
4575}