Skip to main content

aft/
callgraph.rs

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