Skip to main content

aft/
callgraph.rs

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