Skip to main content

aft/commands/
callgraph_store_adapter.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
2use std::path::{Path, PathBuf};
3
4use serde::Serialize;
5use tree_sitter::{Node, Parser};
6
7use crate::callgraph::{self, TraceToSymbolCandidate};
8use crate::callgraph_store::{
9    CallGraphRead, CallGraphStoreError, StoreCallSite, StoreNode, StoreUnresolvedCall,
10};
11use crate::edit::line_col_to_byte;
12use crate::error::AftError;
13use crate::inspect::job::is_test_file;
14use crate::parser::{
15    detect_language, extract_symbols_from_tree, grammar_for, FileParser, SharedSymbolCache,
16};
17use crate::protocol::Response;
18use crate::symbols::Symbol;
19
20pub type StoreAdapterResult<T> = Result<T, CallGraphStoreError>;
21
22const TRACE_DATA_RESOLVER_PROVENANCE: &str = "treesitter+resolver";
23const HUB_SUMMARY_THRESHOLD: usize = 20;
24const HUB_SUMMARY_LIMIT: usize = 15;
25// The agent only receives 15 representative paths once a trace becomes a hub. A
26// 10k expansion budget leaves ample room for ordinary traces while preventing a
27// layered call graph from unfolding millions of path prefixes synchronously.
28const TRACE_TO_EXPANSION_BUDGET: usize = 10_000;
29const TRACE_TO_RETAINED_PATH_LIMIT: usize = HUB_SUMMARY_LIMIT * 4;
30
31fn is_false(value: &bool) -> bool {
32    !*value
33}
34
35#[derive(Debug, Clone, Serialize)]
36pub struct StoreHubSummary {
37    pub message: String,
38    pub total: usize,
39    pub hidden_tests: usize,
40    pub shown: usize,
41    pub threshold: usize,
42    pub limit: usize,
43    #[serde(skip_serializing_if = "is_false")]
44    pub counts_are_lower_bounds: bool,
45}
46
47#[derive(Debug, Clone, Default)]
48struct EdgeMarker {
49    approximate: Option<bool>,
50    resolved_by: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize)]
54pub struct StoreCallersResult {
55    pub symbol: String,
56    pub file: String,
57    pub callers: Vec<StoreCallerGroup>,
58    pub total_callers: usize,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub hub_summary: Option<StoreHubSummary>,
61    pub scanned_files: usize,
62    pub depth_limited: bool,
63    pub truncated: usize,
64}
65
66#[derive(Debug, Clone, Serialize)]
67pub struct StoreCallerGroup {
68    pub file: String,
69    pub callers: Vec<StoreCallerEntry>,
70}
71
72#[derive(Debug, Clone, Serialize)]
73pub struct StoreCallerEntry {
74    pub symbol: String,
75    pub line: u32,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub approximate: Option<bool>,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub resolved_by: Option<String>,
80}
81
82#[derive(Debug, Clone, Serialize)]
83pub struct StoreCallTreeNode {
84    pub name: String,
85    pub file: String,
86    pub line: u32,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub signature: Option<String>,
89    pub resolved: bool,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub approximate: Option<bool>,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub resolved_by: Option<String>,
94    pub children: Vec<StoreCallTreeNode>,
95    pub depth_limited: bool,
96    pub truncated: usize,
97}
98
99#[derive(Debug, Clone, Serialize)]
100pub struct StoreImpactResult {
101    pub symbol: String,
102    pub file: String,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub signature: Option<String>,
105    pub parameters: Vec<String>,
106    pub total_affected: usize,
107    pub affected_files: usize,
108    pub callers: Vec<StoreImpactCaller>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub hub_summary: Option<StoreHubSummary>,
111    pub depth_limited: bool,
112    pub truncated: usize,
113}
114
115#[derive(Debug, Clone, Serialize)]
116pub struct StoreImpactCaller {
117    pub caller_symbol: String,
118    pub caller_file: String,
119    pub line: u32,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub signature: Option<String>,
122    pub is_entry_point: bool,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub call_expression: Option<String>,
125    pub parameters: Vec<String>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub approximate: Option<bool>,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub resolved_by: Option<String>,
130}
131
132#[derive(Debug, Clone, Serialize)]
133pub struct StoreTraceHop {
134    pub symbol: String,
135    pub file: String,
136    pub line: u32,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub signature: Option<String>,
139    pub is_entry_point: bool,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub approximate: Option<bool>,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub resolved_by: Option<String>,
144}
145
146#[derive(Debug, Clone, Serialize)]
147pub struct StoreTracePath {
148    pub hops: Vec<StoreTraceHop>,
149}
150
151#[derive(Debug, Clone, Serialize)]
152pub struct StoreTraceToResult {
153    pub target_symbol: String,
154    pub target_file: String,
155    pub paths: Vec<StoreTracePath>,
156    pub total_paths: usize,
157    #[serde(skip_serializing_if = "is_false")]
158    pub total_paths_is_lower_bound: bool,
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub hub_summary: Option<StoreHubSummary>,
161    pub entry_points_found: usize,
162    pub max_depth_reached: bool,
163    pub truncated_paths: usize,
164}
165
166#[derive(Debug, Clone, Serialize)]
167pub struct StoreTraceToSymbolHop {
168    pub symbol: String,
169    pub file: String,
170    pub line: u32,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub approximate: Option<bool>,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub resolved_by: Option<String>,
175}
176
177#[derive(Debug, Clone, Serialize)]
178pub struct StoreTraceToSymbolResult {
179    pub path: Option<Vec<StoreTraceToSymbolHop>>,
180    pub complete: bool,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub reason: Option<String>,
183}
184
185enum ForwardCall {
186    Resolved(StoreCallSite),
187    Unresolved(StoreUnresolvedCall),
188}
189
190#[derive(Clone)]
191enum TraceForwardCall {
192    Resolved(StoreCallSite),
193    Unresolved(StoreUnresolvedCall),
194}
195
196impl TraceForwardCall {
197    fn byte_start(&self) -> usize {
198        match self {
199            Self::Resolved(site) => site.byte_start,
200            Self::Unresolved(call) => call.byte_start,
201        }
202    }
203
204    fn byte_end(&self) -> usize {
205        match self {
206            Self::Resolved(site) => site.byte_end,
207            Self::Unresolved(call) => call.byte_end,
208        }
209    }
210
211    fn line(&self) -> u32 {
212        match self {
213            Self::Resolved(site) => site.line,
214            Self::Unresolved(call) => call.line,
215        }
216    }
217
218    fn matches_position(&self, byte_start: usize, byte_end: usize) -> bool {
219        self.byte_start() == byte_start && self.byte_end() == byte_end
220    }
221}
222
223impl ForwardCall {
224    fn byte_start(&self) -> usize {
225        match self {
226            Self::Resolved(site) => site.byte_start,
227            Self::Unresolved(call) => call.byte_start,
228        }
229    }
230
231    fn line(&self) -> u32 {
232        match self {
233            Self::Resolved(site) => site.line,
234            Self::Unresolved(call) => call.line,
235        }
236    }
237
238    fn call_site_key(&self) -> (String, u32, String) {
239        match self {
240            Self::Resolved(site) => (
241                site.caller.file.clone(),
242                site.line,
243                format!("{}::{}", site.target_file, site.target_symbol),
244            ),
245            Self::Unresolved(call) => (call.caller.file.clone(), call.line, call.symbol.clone()),
246        }
247    }
248}
249
250#[derive(Clone)]
251struct ResolvedStoreSymbol {
252    representative: StoreNode,
253    nodes: Vec<StoreNode>,
254}
255
256#[derive(Clone)]
257struct TraceElem {
258    node: StoreNode,
259    edge: EdgeMarker,
260}
261
262fn edge_marker(site: &StoreCallSite) -> EdgeMarker {
263    if let Some(resolved_by) = site.supplemental_resolution() {
264        EdgeMarker {
265            approximate: Some(site.approximate()),
266            resolved_by: Some(resolved_by.to_string()),
267        }
268    } else {
269        EdgeMarker::default()
270    }
271}
272
273fn edge_approximate(site: &StoreCallSite) -> Option<bool> {
274    site.supplemental_resolution().map(|_| site.approximate())
275}
276
277fn edge_resolved_by(site: &StoreCallSite) -> Option<String> {
278    site.supplemental_resolution().map(ToString::to_string)
279}
280
281fn test_hidden_summary(
282    kind: &str,
283    total: usize,
284    hidden_tests: usize,
285    shown: usize,
286) -> StoreHubSummary {
287    StoreHubSummary {
288        message: format!(
289            "Next: {total} {kind} ({hidden_tests} in tests, hidden — pass includeTests) — narrow with scope"
290        ),
291        total,
292        hidden_tests,
293        shown,
294        threshold: HUB_SUMMARY_THRESHOLD,
295        limit: HUB_SUMMARY_LIMIT,
296        counts_are_lower_bounds: false,
297    }
298}
299
300fn included_summary(
301    kind: &str,
302    total: usize,
303    hidden_tests: usize,
304    shown: usize,
305) -> StoreHubSummary {
306    let test_note = if hidden_tests == 0 {
307        String::new()
308    } else {
309        format!(" ({hidden_tests} in tests, included)")
310    };
311    StoreHubSummary {
312        message: format!("Next: {total} {kind}{test_note} — showing {shown}; narrow with scope"),
313        total,
314        hidden_tests,
315        shown,
316        threshold: HUB_SUMMARY_THRESHOLD,
317        limit: HUB_SUMMARY_LIMIT,
318        counts_are_lower_bounds: false,
319    }
320}
321
322fn lower_bound_trace_summary(
323    total: usize,
324    hidden_tests: usize,
325    shown: usize,
326    include_tests: bool,
327) -> StoreHubSummary {
328    let test_note = if include_tests {
329        if hidden_tests == 0 {
330            " (test-path count also incomplete)".to_string()
331        } else {
332            format!(" (at least {hidden_tests} in tests, included)")
333        }
334    } else if hidden_tests == 0 {
335        " (additional test paths may be uncounted — pass includeTests)".to_string()
336    } else {
337        format!(" (at least {hidden_tests} in tests, hidden — pass includeTests)")
338    };
339    StoreHubSummary {
340        message: format!(
341            "Next: at least {total} paths{test_note} — showing {shown}; traversal capped; narrow with scope"
342        ),
343        total,
344        hidden_tests,
345        shown,
346        threshold: HUB_SUMMARY_THRESHOLD,
347        limit: HUB_SUMMARY_LIMIT,
348        counts_are_lower_bounds: true,
349    }
350}
351
352fn callsite_is_from_test(site: &StoreCallSite) -> bool {
353    is_test_file(&site.caller.file)
354}
355
356fn trace_path_starts_in_test(path: &StoreTracePath) -> bool {
357    path.hops.first().is_some_and(|hop| is_test_file(&hop.file))
358}
359
360fn dedup_sites_for_summary(sites: Vec<StoreCallSite>) -> Vec<StoreCallSite> {
361    let mut seen = BTreeSet::new();
362    sites
363        .into_iter()
364        .filter(|site| seen.insert((site.caller.symbol.clone(), site.target_symbol.clone())))
365        .collect()
366}
367
368fn trace_path_shape(path: &StoreTracePath) -> Vec<(String, String)> {
369    path.hops
370        .iter()
371        .map(|hop| (hop.file.clone(), hop.symbol.clone()))
372        .collect()
373}
374
375fn dedup_paths_for_summary(paths: Vec<StoreTracePath>) -> Vec<StoreTracePath> {
376    let mut seen = BTreeSet::new();
377    paths
378        .into_iter()
379        .filter(|path| seen.insert(trace_path_shape(path)))
380        .collect()
381}
382
383fn trace_path_order(left: &StoreTracePath, right: &StoreTracePath) -> std::cmp::Ordering {
384    let left_entry = left
385        .hops
386        .first()
387        .map(|hop| hop.symbol.as_str())
388        .unwrap_or("");
389    let right_entry = right
390        .hops
391        .first()
392        .map(|hop| hop.symbol.as_str())
393        .unwrap_or("");
394    left_entry
395        .cmp(right_entry)
396        .then(left.hops.len().cmp(&right.hops.len()))
397}
398
399fn store_trace_path(elems: &[TraceElem]) -> StoreTracePath {
400    let hops = elems
401        .iter()
402        .rev()
403        .enumerate()
404        .map(|(index, elem)| StoreTraceHop {
405            symbol: elem.node.symbol.clone(),
406            file: elem.node.file.clone(),
407            line: elem.node.line,
408            signature: elem.node.signature.clone(),
409            is_entry_point: index == 0 && elem.node.is_entry_point,
410            approximate: elem.edge.approximate,
411            resolved_by: elem.edge.resolved_by.clone(),
412        })
413        .collect();
414    StoreTracePath { hops }
415}
416
417fn retain_trace_path(retained: &mut Vec<StoreTracePath>, path: StoreTracePath) {
418    retained.push(path);
419    if retained.len() <= TRACE_TO_RETAINED_PATH_LIMIT {
420        return;
421    }
422    retained.sort_by(trace_path_order);
423    *retained = dedup_paths_for_summary(std::mem::take(retained))
424        .into_iter()
425        .take(TRACE_TO_RETAINED_PATH_LIMIT)
426        .collect();
427}
428
429fn filter_call_tree_tests(node: &mut StoreCallTreeNode) {
430    node.children.retain(|child| !is_test_file(&child.file));
431    for child in &mut node.children {
432        filter_call_tree_tests(child);
433    }
434}
435
436pub fn callers_result(
437    store: &impl CallGraphRead,
438    file: &Path,
439    symbol: &str,
440    depth: usize,
441    include_tests: bool,
442) -> StoreAdapterResult<StoreCallersResult> {
443    let target = resolve_symbol_query(store, file, symbol)?;
444    let effective_depth = depth.max(1);
445    let mut visited = HashSet::new();
446    let mut sites = Vec::new();
447    let mut depth_limited = false;
448    let mut truncated = 0usize;
449
450    collect_callers_recursive(
451        store,
452        &target.representative.file,
453        &target.representative.symbol,
454        effective_depth,
455        0,
456        &mut visited,
457        &mut sites,
458        &mut depth_limited,
459        &mut truncated,
460    )?;
461
462    let mut sites = dedup_call_sites(sites);
463    sites.sort_by(|left, right| {
464        left.caller
465            .file
466            .cmp(&right.caller.file)
467            .then(left.line.cmp(&right.line))
468            .then(left.caller.symbol.cmp(&right.caller.symbol))
469    });
470    let total_callers = sites.len();
471    let hidden_tests = sites
472        .iter()
473        .filter(|site| callsite_is_from_test(site))
474        .count();
475    let summarize = total_callers > HUB_SUMMARY_THRESHOLD;
476    let visible_sites = sites
477        .into_iter()
478        .filter(|site| include_tests || !callsite_is_from_test(site))
479        .collect::<Vec<_>>();
480    let visible_sites = if summarize {
481        dedup_sites_for_summary(visible_sites)
482            .into_iter()
483            .take(HUB_SUMMARY_LIMIT)
484            .collect::<Vec<_>>()
485    } else {
486        visible_sites
487    };
488    let hub_summary = if summarize {
489        Some(if include_tests {
490            included_summary("callers", total_callers, hidden_tests, visible_sites.len())
491        } else {
492            test_hidden_summary("callers", total_callers, hidden_tests, visible_sites.len())
493        })
494    } else {
495        None
496    };
497    let mut groups: BTreeMap<String, Vec<StoreCallerEntry>> = BTreeMap::new();
498    for site in visible_sites {
499        groups
500            .entry(site.caller.file.clone())
501            .or_default()
502            .push(StoreCallerEntry {
503                symbol: site.caller.symbol.clone(),
504                line: site.line,
505                approximate: edge_approximate(&site),
506                resolved_by: edge_resolved_by(&site),
507            });
508    }
509
510    Ok(StoreCallersResult {
511        symbol: target.representative.symbol,
512        file: target.representative.file,
513        callers: groups
514            .into_iter()
515            .map(|(file, callers)| StoreCallerGroup { file, callers })
516            .collect(),
517        total_callers,
518        hub_summary,
519        scanned_files: store.indexed_file_count()?,
520        depth_limited,
521        truncated,
522    })
523}
524
525pub fn call_tree_result(
526    store: &impl CallGraphRead,
527    file: &Path,
528    symbol: &str,
529    depth: usize,
530    include_tests: bool,
531) -> StoreAdapterResult<StoreCallTreeNode> {
532    let target = resolve_symbol_query(store, file, symbol)?;
533    let mut visited = HashSet::new();
534    let mut tree = call_tree_inner(store, &target, depth, 0, &mut visited)?;
535    if !include_tests {
536        filter_call_tree_tests(&mut tree);
537    }
538    Ok(tree)
539}
540
541pub fn impact_result(
542    store: &impl CallGraphRead,
543    file: &Path,
544    symbol: &str,
545    depth: usize,
546    include_tests: bool,
547) -> StoreAdapterResult<StoreImpactResult> {
548    let target = resolve_symbol_query(store, file, symbol)?;
549    let effective_depth = depth.max(1);
550    let mut visited = HashSet::new();
551    let mut sites = Vec::new();
552    let mut depth_limited = false;
553    let mut truncated = 0usize;
554
555    collect_callers_recursive(
556        store,
557        &target.representative.file,
558        &target.representative.symbol,
559        effective_depth,
560        0,
561        &mut visited,
562        &mut sites,
563        &mut depth_limited,
564        &mut truncated,
565    )?;
566
567    let mut sites = dedup_call_sites(sites);
568    sites.sort_by(|left, right| {
569        left.caller
570            .file
571            .cmp(&right.caller.file)
572            .then(left.line.cmp(&right.line))
573            .then(left.caller.symbol.cmp(&right.caller.symbol))
574    });
575    let total_affected = sites.len();
576    let hidden_tests = sites
577        .iter()
578        .filter(|site| callsite_is_from_test(site))
579        .count();
580    let summarize = total_affected > HUB_SUMMARY_THRESHOLD;
581    let affected_files = sites
582        .iter()
583        .map(|site| site.caller.file.clone())
584        .collect::<BTreeSet<_>>()
585        .len();
586    let visible_sites = sites
587        .into_iter()
588        .filter(|site| include_tests || !callsite_is_from_test(site))
589        .collect::<Vec<_>>();
590    let visible_sites = if summarize {
591        dedup_sites_for_summary(visible_sites)
592            .into_iter()
593            .take(HUB_SUMMARY_LIMIT)
594            .collect::<Vec<_>>()
595    } else {
596        visible_sites
597    };
598    let hub_summary = if summarize {
599        Some(if include_tests {
600            included_summary(
601                "affected callers",
602                total_affected,
603                hidden_tests,
604                visible_sites.len(),
605            )
606        } else {
607            test_hidden_summary(
608                "affected callers",
609                total_affected,
610                hidden_tests,
611                visible_sites.len(),
612            )
613        })
614    } else {
615        None
616    };
617    let target_signature = target.representative.signature.clone();
618    let target_parameters = target_signature
619        .as_deref()
620        .map(|signature| callgraph::extract_parameters(signature, target.representative.lang))
621        .unwrap_or_default();
622
623    let mut callers = Vec::new();
624    for site in visible_sites {
625        callers.push(StoreImpactCaller {
626            caller_symbol: site.caller.symbol.clone(),
627            caller_file: site.caller.file.clone(),
628            line: site.line,
629            signature: site.caller.signature.clone(),
630            is_entry_point: site.caller.is_entry_point,
631            call_expression: read_source_line(
632                &store.project_root().join(&site.caller.file),
633                site.line,
634            ),
635            parameters: site
636                .caller
637                .signature
638                .as_deref()
639                .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
640                .unwrap_or_default(),
641            approximate: edge_approximate(&site),
642            resolved_by: edge_resolved_by(&site),
643        });
644    }
645    callers.sort_by(|left, right| {
646        left.caller_file
647            .cmp(&right.caller_file)
648            .then(left.line.cmp(&right.line))
649    });
650
651    Ok(StoreImpactResult {
652        symbol: target.representative.symbol,
653        file: target.representative.file,
654        signature: target_signature,
655        parameters: target_parameters,
656        total_affected,
657        affected_files,
658        callers,
659        hub_summary,
660        depth_limited,
661        truncated,
662    })
663}
664
665pub fn trace_to_result(
666    store: &impl CallGraphRead,
667    file: &Path,
668    symbol: &str,
669    max_depth: usize,
670    include_tests: bool,
671) -> StoreAdapterResult<StoreTraceToResult> {
672    trace_to_result_with_budget(
673        store,
674        file,
675        symbol,
676        max_depth,
677        include_tests,
678        TRACE_TO_EXPANSION_BUDGET,
679    )
680    .map(|(result, _)| result)
681}
682
683fn trace_to_result_with_budget(
684    store: &impl CallGraphRead,
685    file: &Path,
686    symbol: &str,
687    max_depth: usize,
688    include_tests: bool,
689    expansion_budget: usize,
690) -> StoreAdapterResult<(StoreTraceToResult, usize)> {
691    let target = resolve_symbol_query(store, file, symbol)?;
692    let effective_max = if max_depth == 0 { 10 } else { max_depth };
693
694    let initial = vec![TraceElem {
695        node: target.representative.clone(),
696        edge: EdgeMarker::default(),
697    }];
698    let mut retained_paths = Vec::new();
699    let mut total_paths = 0usize;
700    let mut hidden_tests = 0usize;
701    if target.representative.is_entry_point {
702        total_paths = 1;
703        let path = store_trace_path(&initial);
704        if trace_path_starts_in_test(&path) {
705            hidden_tests = 1;
706        }
707        if include_tests || hidden_tests == 0 {
708            retain_trace_path(&mut retained_paths, path);
709        }
710    }
711
712    let mut queue = vec![(initial, 0usize)];
713    let mut max_depth_reached = false;
714    let mut truncated_paths = 0usize;
715    let mut expansions = 0usize;
716    let mut budget_exhausted = false;
717    let mut callers_by_symbol: HashMap<(String, String), Vec<StoreCallSite>> = HashMap::new();
718
719    'traversal: while let Some((path, depth)) = queue.pop() {
720        if expansions >= expansion_budget {
721            budget_exhausted = true;
722            break;
723        }
724        expansions += 1;
725        if depth >= effective_max {
726            max_depth_reached = true;
727            continue;
728        }
729        let Some(current) = path.last() else {
730            continue;
731        };
732        let caller_key = (current.node.file.clone(), current.node.symbol.clone());
733        if let std::collections::hash_map::Entry::Vacant(entry) =
734            callers_by_symbol.entry(caller_key.clone())
735        {
736            let callers =
737                dedup_call_sites(store.direct_callers_of(Path::new(&caller_key.0), &caller_key.1)?);
738            entry.insert(callers);
739        }
740        let callers = callers_by_symbol
741            .get(&caller_key)
742            .expect("trace caller cache populated above");
743        if callers.is_empty() {
744            if path.len() > 1 {
745                truncated_paths += 1;
746            }
747            continue;
748        }
749
750        let mut has_new_path = false;
751        for site in callers {
752            if path.iter().any(|elem| {
753                elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
754            }) {
755                continue;
756            }
757            has_new_path = true;
758            let mut next_path = path.clone();
759            if let Some(current) = next_path.last_mut() {
760                current.edge = edge_marker(&site);
761            }
762            next_path.push(TraceElem {
763                node: site.caller.clone(),
764                edge: EdgeMarker::default(),
765            });
766            if site.caller.is_entry_point {
767                total_paths = total_paths.saturating_add(1);
768                let completed = store_trace_path(&next_path);
769                let from_test = trace_path_starts_in_test(&completed);
770                if from_test {
771                    hidden_tests = hidden_tests.saturating_add(1);
772                }
773                if include_tests || !from_test {
774                    retain_trace_path(&mut retained_paths, completed);
775                }
776            }
777            // Reserve at most one future expansion slot per queued path. This
778            // bounds queue memory as well as the number of paths actually popped.
779            if expansions.saturating_add(queue.len()) >= expansion_budget {
780                budget_exhausted = true;
781                break 'traversal;
782            }
783            queue.push((next_path, depth + 1));
784        }
785        if !has_new_path && path.len() > 1 {
786            truncated_paths += 1;
787        }
788    }
789
790    retained_paths.sort_by(trace_path_order);
791    let summarize = budget_exhausted || total_paths > HUB_SUMMARY_THRESHOLD;
792    let paths = if summarize {
793        dedup_paths_for_summary(retained_paths)
794            .into_iter()
795            .take(HUB_SUMMARY_LIMIT)
796            .collect::<Vec<_>>()
797    } else {
798        retained_paths
799    };
800    let hub_summary = if summarize {
801        Some(if budget_exhausted {
802            lower_bound_trace_summary(total_paths, hidden_tests, paths.len(), include_tests)
803        } else if include_tests {
804            included_summary("paths", total_paths, hidden_tests, paths.len())
805        } else {
806            test_hidden_summary("paths", total_paths, hidden_tests, paths.len())
807        })
808    } else {
809        None
810    };
811
812    let entry_points_found = paths
813        .iter()
814        .filter_map(|path| path.hops.first())
815        .filter(|hop| hop.is_entry_point)
816        .map(|hop| (hop.file.clone(), hop.symbol.clone()))
817        .collect::<HashSet<_>>()
818        .len();
819
820    Ok((
821        StoreTraceToResult {
822            target_symbol: target.representative.symbol,
823            target_file: target.representative.file,
824            total_paths,
825            total_paths_is_lower_bound: budget_exhausted,
826            hub_summary,
827            paths,
828            entry_points_found,
829            max_depth_reached,
830            truncated_paths,
831        },
832        expansions,
833    ))
834}
835
836pub fn ensure_symbol_resolves(
837    store: &impl CallGraphRead,
838    file: &Path,
839    symbol: &str,
840) -> StoreAdapterResult<()> {
841    resolve_symbol_query(store, file, symbol).map(|_| ())
842}
843
844pub fn trace_to_symbol_candidates(
845    store: &impl CallGraphRead,
846    to_symbol: &str,
847) -> StoreAdapterResult<Vec<TraceToSymbolCandidate>> {
848    store.trace_to_symbol_candidates(to_symbol)
849}
850
851pub fn trace_to_symbol_result(
852    store: &impl CallGraphRead,
853    file: &Path,
854    symbol: &str,
855    to_symbol: &str,
856    to_file: Option<&Path>,
857    max_depth: usize,
858    include_tests: bool,
859) -> StoreAdapterResult<StoreTraceToSymbolResult> {
860    let origin = resolve_symbol_query(store, file, symbol)?;
861    let target_file = to_file.map(|path| relative_file(store, path));
862    let effective_max = if max_depth == 0 {
863        10
864    } else {
865        max_depth.min(16)
866    };
867
868    let start_hop = trace_to_symbol_hop(&origin.representative);
869    if trace_to_symbol_matches_target(
870        &origin.representative.file,
871        &origin.representative.symbol,
872        to_symbol,
873        target_file.as_deref(),
874    ) {
875        return Ok(StoreTraceToSymbolResult {
876            path: Some(vec![start_hop]),
877            complete: true,
878            reason: None,
879        });
880    }
881
882    let mut queue = VecDeque::new();
883    queue.push_back((
884        origin.representative.file.clone(),
885        origin.representative.symbol.clone(),
886        vec![start_hop],
887        0usize,
888    ));
889    let mut visited = HashSet::new();
890    visited.insert((
891        origin.representative.file.clone(),
892        origin.representative.symbol.clone(),
893    ));
894    let mut max_depth_exhausted = false;
895
896    while let Some((current_file, current_symbol, path, depth)) = queue.pop_front() {
897        let callees = forward_resolved_callees(store, &current_file, &current_symbol)?;
898
899        if depth >= effective_max {
900            if callees
901                .iter()
902                .any(|(node, _)| !visited.contains(&(node.file.clone(), node.symbol.clone())))
903            {
904                max_depth_exhausted = true;
905            }
906            continue;
907        }
908
909        for (callee, edge) in callees {
910            if !include_tests && is_test_file(&callee.file) {
911                continue;
912            }
913            if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
914                continue;
915            }
916            let mut next_path = path.clone();
917            next_path.push(trace_to_symbol_hop_with_edge(&callee, edge));
918            if trace_to_symbol_matches_target(
919                &callee.file,
920                &callee.symbol,
921                to_symbol,
922                target_file.as_deref(),
923            ) {
924                return Ok(StoreTraceToSymbolResult {
925                    path: Some(next_path),
926                    complete: true,
927                    reason: None,
928                });
929            }
930            queue.push_back((callee.file, callee.symbol, next_path, depth + 1));
931        }
932    }
933
934    if max_depth_exhausted {
935        Ok(StoreTraceToSymbolResult {
936            path: None,
937            complete: false,
938            reason: Some("max_depth_exhausted".to_string()),
939        })
940    } else {
941        Ok(StoreTraceToSymbolResult {
942            path: None,
943            complete: true,
944            reason: Some("no_path_found".to_string()),
945        })
946    }
947}
948
949pub fn trace_data_result(
950    store: &impl CallGraphRead,
951    file: &Path,
952    symbol: &str,
953    expression: &str,
954    max_depth: usize,
955    symbol_cache: SharedSymbolCache,
956) -> StoreAdapterResult<callgraph::TraceDataResult> {
957    let origin_path = absolute_file(store, file);
958    let origin_file = relative_file(store, &origin_path);
959    let origin_symbol = resolve_symbol_query_with_cache(&origin_path, symbol, &symbol_cache)?;
960
961    let mut hops = Vec::new();
962    let mut depth_limited = false;
963    let mut visited = HashSet::new();
964    trace_data_inner(
965        store,
966        &symbol_cache,
967        &origin_path,
968        &origin_symbol,
969        expression,
970        max_depth,
971        0,
972        &mut hops,
973        &mut depth_limited,
974        &mut visited,
975    )?;
976
977    Ok(callgraph::TraceDataResult {
978        expression: expression.to_string(),
979        origin_file,
980        origin_symbol,
981        hops,
982        depth_limited,
983    })
984}
985
986#[allow(clippy::too_many_arguments)]
987fn trace_data_inner(
988    store: &impl CallGraphRead,
989    symbol_cache: &SharedSymbolCache,
990    file: &Path,
991    symbol: &str,
992    tracking_name: &str,
993    max_depth: usize,
994    current_depth: usize,
995    hops: &mut Vec<callgraph::DataFlowHop>,
996    depth_limited: &mut bool,
997    visited: &mut HashSet<(String, String, String)>,
998) -> StoreAdapterResult<()> {
999    let rel_file = relative_file(store, file);
1000    let visit_key = (
1001        rel_file.clone(),
1002        symbol.to_string(),
1003        tracking_name.to_string(),
1004    );
1005    if visited.contains(&visit_key) {
1006        return Ok(());
1007    }
1008    visited.insert(visit_key);
1009
1010    let current = resolve_exact_symbol(store, &rel_file, symbol, None)?
1011        .ok_or_else(|| CallGraphStoreError::StaleFiles(vec![rel_file.clone()]))?;
1012    let current_calls = trace_forward_calls_for_nodes(store, &current.nodes)?;
1013
1014    // Keep the legacy value-flow posture: parse the current source for body walks
1015    // and use the store only for cross-hop call facts.
1016    let source = std::fs::read_to_string(file)?;
1017    let Some(lang) = detect_language(file) else {
1018        return Ok(());
1019    };
1020    let grammar = grammar_for(lang);
1021    let mut parser = Parser::new();
1022    parser
1023        .set_language(&grammar)
1024        .map_err(|error| AftError::ParseError {
1025            message: format!("grammar init failed for {:?}: {}", lang, error),
1026        })?;
1027    let tree = parser
1028        .parse(&source, None)
1029        .ok_or_else(|| AftError::ParseError {
1030            message: format!("parse failed for {}", file.display()),
1031        })?;
1032    let symbols = extract_symbols_from_tree(&source, &tree, lang)?;
1033    let sym_info = symbols
1034        .iter()
1035        .find(|candidate| {
1036            symbol_identity_from_cache(candidate) == symbol || candidate.name == symbol
1037        })
1038        .ok_or_else(|| CallGraphStoreError::StaleFiles(vec![rel_file.clone()]))?;
1039
1040    let body_start = line_col_to_byte(&source, sym_info.range.start_line, sym_info.range.start_col);
1041    let body_end = line_col_to_byte(&source, sym_info.range.end_line, sym_info.range.end_col);
1042    let Some(body_node) = find_node_covering_range(tree.root_node(), body_start, body_end) else {
1043        return Ok(());
1044    };
1045
1046    let mut tracked_names = vec![tracking_name.to_string()];
1047    walk_for_data_flow(
1048        store,
1049        symbol_cache,
1050        body_node,
1051        &source,
1052        &current_calls,
1053        &mut tracked_names,
1054        symbol,
1055        &rel_file,
1056        max_depth,
1057        current_depth,
1058        hops,
1059        depth_limited,
1060        visited,
1061    )
1062}
1063
1064#[allow(clippy::too_many_arguments)]
1065fn walk_for_data_flow(
1066    store: &impl CallGraphRead,
1067    symbol_cache: &SharedSymbolCache,
1068    node: Node<'_>,
1069    source: &str,
1070    current_calls: &[TraceForwardCall],
1071    tracked_names: &mut Vec<String>,
1072    symbol: &str,
1073    rel_file: &str,
1074    max_depth: usize,
1075    current_depth: usize,
1076    hops: &mut Vec<callgraph::DataFlowHop>,
1077    depth_limited: &mut bool,
1078    visited: &mut HashSet<(String, String, String)>,
1079) -> StoreAdapterResult<()> {
1080    let kind = node.kind();
1081    let is_var_decl = matches!(
1082        kind,
1083        "variable_declarator"
1084            | "assignment_expression"
1085            | "augmented_assignment_expression"
1086            | "assignment"
1087            | "let_declaration"
1088            | "short_var_declaration"
1089    );
1090
1091    if is_var_decl {
1092        if let Some((new_name, init_text, line, is_approx)) =
1093            extract_assignment_info(node, source, tracked_names)
1094        {
1095            if !is_approx {
1096                hops.push(callgraph::DataFlowHop {
1097                    file: rel_file.to_string(),
1098                    symbol: symbol.to_string(),
1099                    variable: new_name.clone(),
1100                    line,
1101                    flow_type: "assignment".to_string(),
1102                    approximate: false,
1103                });
1104                tracked_names.push(new_name);
1105            } else {
1106                hops.push(callgraph::DataFlowHop {
1107                    file: rel_file.to_string(),
1108                    symbol: symbol.to_string(),
1109                    variable: init_text,
1110                    line,
1111                    flow_type: "assignment".to_string(),
1112                    approximate: true,
1113                });
1114                return Ok(());
1115            }
1116        }
1117    }
1118
1119    if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
1120        check_call_for_data_flow(
1121            store,
1122            symbol_cache,
1123            node,
1124            source,
1125            current_calls,
1126            tracked_names,
1127            symbol,
1128            rel_file,
1129            max_depth,
1130            current_depth,
1131            hops,
1132            depth_limited,
1133            visited,
1134        )?;
1135    }
1136
1137    let mut cursor = node.walk();
1138    if cursor.goto_first_child() {
1139        loop {
1140            walk_for_data_flow(
1141                store,
1142                symbol_cache,
1143                cursor.node(),
1144                source,
1145                current_calls,
1146                tracked_names,
1147                symbol,
1148                rel_file,
1149                max_depth,
1150                current_depth,
1151                hops,
1152                depth_limited,
1153                visited,
1154            )?;
1155            if !cursor.goto_next_sibling() {
1156                break;
1157            }
1158        }
1159    }
1160    Ok(())
1161}
1162
1163fn extract_assignment_info(
1164    node: Node<'_>,
1165    source: &str,
1166    tracked_names: &[String],
1167) -> Option<(String, String, u32, bool)> {
1168    let kind = node.kind();
1169    let line = node.start_position().row as u32 + 1;
1170
1171    match kind {
1172        "variable_declarator" => {
1173            let name_node = node.child_by_field_name("name")?;
1174            let value_node = node.child_by_field_name("value")?;
1175            let name_text = trace_node_text(name_node, source);
1176            let value_text = trace_node_text(value_node, source);
1177
1178            if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
1179                if tracked_names
1180                    .iter()
1181                    .any(|tracked| value_text.contains(tracked))
1182                {
1183                    return Some((name_text.clone(), name_text, line, true));
1184                }
1185                return None;
1186            }
1187
1188            if tracked_names.iter().any(|tracked| {
1189                value_text == *tracked
1190                    || value_text.starts_with(&format!("{}.", tracked))
1191                    || value_text.starts_with(&format!("{}[", tracked))
1192            }) {
1193                return Some((name_text, value_text, line, false));
1194            }
1195            None
1196        }
1197        "assignment_expression" | "augmented_assignment_expression" => {
1198            let left = node.child_by_field_name("left")?;
1199            let right = node.child_by_field_name("right")?;
1200            let left_text = trace_node_text(left, source);
1201            let right_text = trace_node_text(right, source);
1202
1203            if tracked_names.iter().any(|tracked| right_text == *tracked) {
1204                return Some((left_text, right_text, line, false));
1205            }
1206            None
1207        }
1208        "assignment" => {
1209            let left = node.child_by_field_name("left")?;
1210            let right = node.child_by_field_name("right")?;
1211            let left_text = trace_node_text(left, source);
1212            let right_text = trace_node_text(right, source);
1213
1214            if tracked_names.iter().any(|tracked| right_text == *tracked) {
1215                return Some((left_text, right_text, line, false));
1216            }
1217            None
1218        }
1219        "let_declaration" | "short_var_declaration" => {
1220            let left = node
1221                .child_by_field_name("pattern")
1222                .or_else(|| node.child_by_field_name("left"))?;
1223            let right = node
1224                .child_by_field_name("value")
1225                .or_else(|| node.child_by_field_name("right"))?;
1226            let left_text = trace_node_text(left, source);
1227            let right_text = trace_node_text(right, source);
1228
1229            if tracked_names.iter().any(|tracked| right_text == *tracked) {
1230                return Some((left_text, right_text, line, false));
1231            }
1232            None
1233        }
1234        _ => None,
1235    }
1236}
1237
1238#[allow(clippy::too_many_arguments)]
1239fn check_call_for_data_flow(
1240    store: &impl CallGraphRead,
1241    symbol_cache: &SharedSymbolCache,
1242    node: Node<'_>,
1243    source: &str,
1244    current_calls: &[TraceForwardCall],
1245    tracked_names: &[String],
1246    symbol: &str,
1247    rel_file: &str,
1248    max_depth: usize,
1249    current_depth: usize,
1250    hops: &mut Vec<callgraph::DataFlowHop>,
1251    depth_limited: &mut bool,
1252    visited: &mut HashSet<(String, String, String)>,
1253) -> StoreAdapterResult<()> {
1254    let args_node =
1255        find_child_by_kind(node, "arguments").or_else(|| find_child_by_kind(node, "argument_list"));
1256    let Some(args_node) = args_node else {
1257        return Ok(());
1258    };
1259
1260    let mut arg_positions = Vec::new();
1261    let mut arg_idx = 0usize;
1262    let mut cursor = args_node.walk();
1263    if cursor.goto_first_child() {
1264        loop {
1265            let child = cursor.node();
1266            let child_kind = child.kind();
1267            if child_kind == "(" || child_kind == ")" || child_kind == "," {
1268                if !cursor.goto_next_sibling() {
1269                    break;
1270                }
1271                continue;
1272            }
1273
1274            let arg_text = trace_node_text(child, source);
1275            if child_kind == "spread_element" || child_kind == "dictionary_splat" {
1276                if tracked_names
1277                    .iter()
1278                    .any(|tracked| arg_text.contains(tracked))
1279                {
1280                    hops.push(callgraph::DataFlowHop {
1281                        file: rel_file.to_string(),
1282                        symbol: symbol.to_string(),
1283                        variable: arg_text,
1284                        line: child.start_position().row as u32 + 1,
1285                        flow_type: "parameter".to_string(),
1286                        approximate: true,
1287                    });
1288                }
1289                if !cursor.goto_next_sibling() {
1290                    break;
1291                }
1292                arg_idx += 1;
1293                continue;
1294            }
1295
1296            if tracked_names.iter().any(|tracked| arg_text == *tracked) {
1297                arg_positions.push((arg_idx, arg_text));
1298            }
1299
1300            arg_idx += 1;
1301            if !cursor.goto_next_sibling() {
1302                break;
1303            }
1304        }
1305    }
1306
1307    if arg_positions.is_empty() {
1308        return Ok(());
1309    }
1310
1311    let matched_call = current_calls
1312        .iter()
1313        .find(|call| call.matches_position(node.start_byte(), node.end_byte()));
1314
1315    match matched_call {
1316        Some(TraceForwardCall::Resolved(site)) => {
1317            let Some(target) = trace_target_node(store, site)? else {
1318                return Ok(());
1319            };
1320            if target.file != rel_file && current_depth + 1 > max_depth {
1321                *depth_limited = true;
1322                return Ok(());
1323            }
1324            let params = target
1325                .signature
1326                .as_deref()
1327                .map(|signature| callgraph::extract_parameters(signature, target.lang))
1328                .unwrap_or_default();
1329            let target_file = store.project_root().join(&target.file);
1330            for (pos, _tracked) in &arg_positions {
1331                if let Some(param_name) = params.get(*pos) {
1332                    hops.push(callgraph::DataFlowHop {
1333                        file: target.file.clone(),
1334                        symbol: target.symbol.clone(),
1335                        variable: param_name.clone(),
1336                        line: target.line,
1337                        flow_type: "parameter".to_string(),
1338                        approximate: false,
1339                    });
1340                    trace_data_inner(
1341                        store,
1342                        symbol_cache,
1343                        &target_file,
1344                        &target.symbol,
1345                        param_name,
1346                        max_depth,
1347                        current_depth + 1,
1348                        hops,
1349                        depth_limited,
1350                        visited,
1351                    )?;
1352                }
1353            }
1354        }
1355        Some(TraceForwardCall::Unresolved(call)) => {
1356            push_unresolved_parameter_hops(hops, rel_file, &call.symbol, &arg_positions, node);
1357        }
1358        None => {
1359            let (_full_callee, short_callee) = extract_callee_names(node, source);
1360            if let Some(callee_name) = short_callee {
1361                push_unresolved_parameter_hops(hops, rel_file, &callee_name, &arg_positions, node);
1362            }
1363        }
1364    }
1365
1366    Ok(())
1367}
1368
1369fn push_unresolved_parameter_hops(
1370    hops: &mut Vec<callgraph::DataFlowHop>,
1371    rel_file: &str,
1372    callee_name: &str,
1373    arg_positions: &[(usize, String)],
1374    call_node: Node<'_>,
1375) {
1376    for (_pos, tracked) in arg_positions {
1377        hops.push(callgraph::DataFlowHop {
1378            file: rel_file.to_string(),
1379            symbol: callee_name.to_string(),
1380            variable: tracked.clone(),
1381            line: call_node.start_position().row as u32 + 1,
1382            flow_type: "parameter".to_string(),
1383            approximate: true,
1384        });
1385    }
1386}
1387
1388fn trace_target_node(
1389    store: &impl CallGraphRead,
1390    site: &StoreCallSite,
1391) -> StoreAdapterResult<Option<StoreNode>> {
1392    if let Some(target) = &site.target {
1393        return Ok(Some(target.clone()));
1394    }
1395    resolve_exact_symbol(store, &site.target_file, &site.target_symbol, None)
1396        .map(|resolved| resolved.map(|symbol| symbol.representative))
1397}
1398
1399fn trace_forward_calls_for_nodes(
1400    store: &impl CallGraphRead,
1401    nodes: &[StoreNode],
1402) -> StoreAdapterResult<Vec<TraceForwardCall>> {
1403    let mut calls = Vec::new();
1404    for node in nodes {
1405        calls.extend(
1406            store
1407                .outgoing_calls_of(node)?
1408                .into_iter()
1409                .filter(|site| site.resolved_by() == TRACE_DATA_RESOLVER_PROVENANCE)
1410                .map(TraceForwardCall::Resolved),
1411        );
1412        calls.extend(
1413            store
1414                .resolved_self_calls_of(node)?
1415                .into_iter()
1416                .filter(|site| site.resolved_by() == TRACE_DATA_RESOLVER_PROVENANCE)
1417                .map(TraceForwardCall::Resolved),
1418        );
1419        calls.extend(
1420            store
1421                .unresolved_calls_of(node)?
1422                .into_iter()
1423                .map(TraceForwardCall::Unresolved),
1424        );
1425    }
1426    calls.sort_by(|left, right| {
1427        left.byte_start()
1428            .cmp(&right.byte_start())
1429            .then(left.byte_end().cmp(&right.byte_end()))
1430            .then(left.line().cmp(&right.line()))
1431    });
1432    Ok(calls)
1433}
1434
1435fn resolve_symbol_query_with_cache(
1436    file: &Path,
1437    symbol: &str,
1438    symbol_cache: &SharedSymbolCache,
1439) -> StoreAdapterResult<String> {
1440    let mut parser = FileParser::with_symbol_cache(symbol_cache.clone());
1441    let symbols = parser.extract_symbols(file)?;
1442    let candidates = symbol_query_candidates_from_symbols(&symbols, symbol);
1443    match candidates.as_slice() {
1444        [candidate] => Ok(candidate.clone()),
1445        [] => Err(AftError::SymbolNotFound {
1446            name: symbol.to_string(),
1447            file: file.display().to_string(),
1448        }
1449        .into()),
1450        _ => Err(AftError::AmbiguousSymbol {
1451            name: symbol.to_string(),
1452            candidates,
1453        }
1454        .into()),
1455    }
1456}
1457
1458fn symbol_query_candidates_from_symbols(symbols: &[Symbol], symbol_name: &str) -> Vec<String> {
1459    let mut seen = HashSet::new();
1460    let mut candidates = Vec::new();
1461    let qualified_query = symbol_name.contains("::");
1462
1463    let mut consider = |candidate: String| {
1464        let matches = if qualified_query {
1465            candidate == symbol_name
1466        } else {
1467            candidate == symbol_name || unqualified_name(&candidate) == symbol_name
1468        };
1469        if matches && seen.insert(candidate.clone()) {
1470            candidates.push(candidate);
1471        }
1472    };
1473
1474    for symbol in symbols {
1475        consider(symbol_identity_from_cache(symbol));
1476        if symbol.exported {
1477            consider(symbol.name.clone());
1478        }
1479    }
1480
1481    candidates.sort();
1482    candidates
1483}
1484
1485fn symbol_identity_from_cache(symbol: &Symbol) -> String {
1486    if symbol.scope_chain.is_empty() {
1487        symbol.name.clone()
1488    } else {
1489        format!("{}::{}", symbol.scope_chain.join("::"), symbol.name)
1490    }
1491}
1492
1493fn trace_node_text(node: Node<'_>, source: &str) -> String {
1494    source[node.start_byte()..node.end_byte()].to_string()
1495}
1496
1497fn find_node_covering_range(root: Node<'_>, start: usize, end: usize) -> Option<Node<'_>> {
1498    let mut best = None;
1499    let mut cursor = root.walk();
1500
1501    fn walk_covering<'a>(
1502        cursor: &mut tree_sitter::TreeCursor<'a>,
1503        start: usize,
1504        end: usize,
1505        best: &mut Option<Node<'a>>,
1506    ) {
1507        let node = cursor.node();
1508        if node.start_byte() <= start && node.end_byte() >= end {
1509            *best = Some(node);
1510            if cursor.goto_first_child() {
1511                loop {
1512                    walk_covering(cursor, start, end, best);
1513                    if !cursor.goto_next_sibling() {
1514                        break;
1515                    }
1516                }
1517                cursor.goto_parent();
1518            }
1519        }
1520    }
1521
1522    walk_covering(&mut cursor, start, end, &mut best);
1523    best
1524}
1525
1526fn find_child_by_kind<'tree>(node: Node<'tree>, kind: &str) -> Option<Node<'tree>> {
1527    let mut cursor = node.walk();
1528    if cursor.goto_first_child() {
1529        loop {
1530            if cursor.node().kind() == kind {
1531                return Some(cursor.node());
1532            }
1533            if !cursor.goto_next_sibling() {
1534                break;
1535            }
1536        }
1537    }
1538    None
1539}
1540
1541fn extract_callee_names(node: Node<'_>, source: &str) -> (Option<String>, Option<String>) {
1542    let Some(callee) = node.child_by_field_name("function") else {
1543        return (None, None);
1544    };
1545    let full = trace_node_text(callee, source);
1546    let short = if full.contains('.') {
1547        full.rsplit('.').next().unwrap_or(&full).to_string()
1548    } else {
1549        full.clone()
1550    };
1551    (Some(full), Some(short))
1552}
1553
1554pub fn store_error_response(req_id: &str, operation: &str, error: CallGraphStoreError) -> Response {
1555    match error {
1556        CallGraphStoreError::Aft(error) => Response::error(req_id, error.code(), error.to_string()),
1557        CallGraphStoreError::Unavailable(message) => Response::error(
1558            req_id,
1559            "callgraph_unavailable",
1560            format!("{operation}: persisted callgraph store unavailable: {message}"),
1561        ),
1562        CallGraphStoreError::StaleFiles(files) => Response::error(
1563            req_id,
1564            "callgraph_stale",
1565            format!(
1566                "{operation}: persisted callgraph store has stale files: {}",
1567                files.join(", ")
1568            ),
1569        ),
1570        other => Response::error(
1571            req_id,
1572            "callgraph_store_error",
1573            format!("{operation}: persisted callgraph store error: {other}"),
1574        ),
1575    }
1576}
1577
1578/// The persisted callgraph store is cold-building in the background. The op did
1579/// not block the request thread; the agent should retry shortly. Mirrors how
1580/// semantic search reports a build in progress.
1581pub fn building_response(req_id: &str, operation: &str) -> Response {
1582    Response::error(
1583        req_id,
1584        "callgraph_building",
1585        format!("{operation}: callgraph store is building in the background; retry shortly"),
1586    )
1587}
1588
1589pub fn unavailable_response(req_id: &str, operation: &str, worktree: bool) -> Response {
1590    let message = if worktree {
1591        format!(
1592            "{operation}: persisted callgraph store is unavailable in this read-only worktree; run a callgraph operation in the main checkout to build it first"
1593        )
1594    } else {
1595        format!("{operation}: project not configured — send 'configure' first")
1596    };
1597    let code = if worktree {
1598        "callgraph_unavailable"
1599    } else {
1600        "not_configured"
1601    };
1602    Response::error(req_id, code, message)
1603}
1604
1605fn resolve_symbol_query(
1606    store: &impl CallGraphRead,
1607    file: &Path,
1608    symbol: &str,
1609) -> StoreAdapterResult<ResolvedStoreSymbol> {
1610    let nodes = store.nodes_for(file, symbol)?;
1611    collapse_symbol_nodes(store, file, symbol, nodes)
1612}
1613
1614fn resolve_exact_symbol(
1615    store: &impl CallGraphRead,
1616    file: &str,
1617    symbol: &str,
1618    fallback: Option<StoreNode>,
1619) -> StoreAdapterResult<Option<ResolvedStoreSymbol>> {
1620    let nodes = store
1621        .nodes_for(Path::new(file), symbol)?
1622        .into_iter()
1623        .filter(|node| node.symbol == symbol)
1624        .collect::<Vec<_>>();
1625    if nodes.is_empty() {
1626        return Ok(fallback.map(|node| ResolvedStoreSymbol {
1627            representative: node.clone(),
1628            nodes: vec![node],
1629        }));
1630    }
1631    Ok(Some(collapse_exact_nodes(nodes)))
1632}
1633
1634fn collapse_symbol_nodes(
1635    store: &impl CallGraphRead,
1636    file: &Path,
1637    query: &str,
1638    nodes: Vec<StoreNode>,
1639) -> StoreAdapterResult<ResolvedStoreSymbol> {
1640    let mut by_symbol: BTreeMap<String, Vec<StoreNode>> = BTreeMap::new();
1641    for node in nodes {
1642        by_symbol.entry(node.symbol.clone()).or_default().push(node);
1643    }
1644
1645    match by_symbol.len() {
1646        0 => Err(CallGraphStoreError::Aft(AftError::SymbolNotFound {
1647            name: query.to_string(),
1648            file: display_file_for_error(store, file),
1649        })),
1650        1 => Ok(collapse_exact_nodes(
1651            by_symbol.into_values().next().unwrap_or_default(),
1652        )),
1653        _ => Err(CallGraphStoreError::Aft(AftError::AmbiguousSymbol {
1654            name: query.to_string(),
1655            candidates: by_symbol.into_keys().collect(),
1656        })),
1657    }
1658}
1659
1660fn collapse_exact_nodes(mut nodes: Vec<StoreNode>) -> ResolvedStoreSymbol {
1661    nodes.sort_by(|left, right| {
1662        left.symbol
1663            .cmp(&right.symbol)
1664            .then(left.line.cmp(&right.line))
1665            .then(left.end_line.cmp(&right.end_line))
1666    });
1667    let representative = nodes[0].clone();
1668    ResolvedStoreSymbol {
1669        representative,
1670        nodes,
1671    }
1672}
1673
1674#[allow(clippy::too_many_arguments)]
1675fn collect_callers_recursive(
1676    store: &impl CallGraphRead,
1677    file: &str,
1678    symbol: &str,
1679    max_depth: usize,
1680    current_depth: usize,
1681    visited: &mut HashSet<(String, String)>,
1682    result: &mut Vec<StoreCallSite>,
1683    depth_limited: &mut bool,
1684    truncated: &mut usize,
1685) -> StoreAdapterResult<()> {
1686    if current_depth >= max_depth {
1687        let omitted = dedup_call_site_count(store.direct_callers_of(Path::new(file), symbol)?);
1688        if omitted > 0 {
1689            *depth_limited = true;
1690            *truncated += omitted;
1691        }
1692        return Ok(());
1693    }
1694
1695    if !visited.insert((file.to_string(), symbol.to_string())) {
1696        return Ok(());
1697    }
1698
1699    let sites = store.direct_callers_of(Path::new(file), symbol)?;
1700    for site in sites {
1701        result.push(site.clone());
1702        if current_depth + 1 < max_depth {
1703            collect_callers_recursive(
1704                store,
1705                &site.caller.file,
1706                &site.caller.symbol,
1707                max_depth,
1708                current_depth + 1,
1709                visited,
1710                result,
1711                depth_limited,
1712                truncated,
1713            )?;
1714        } else {
1715            let omitted = dedup_call_site_count(
1716                store.direct_callers_of(Path::new(&site.caller.file), &site.caller.symbol)?,
1717            );
1718            if omitted > 0 {
1719                *depth_limited = true;
1720                *truncated += omitted;
1721            }
1722        }
1723    }
1724    Ok(())
1725}
1726
1727fn call_tree_inner(
1728    store: &impl CallGraphRead,
1729    current: &ResolvedStoreSymbol,
1730    max_depth: usize,
1731    current_depth: usize,
1732    visited: &mut HashSet<(String, String)>,
1733) -> StoreAdapterResult<StoreCallTreeNode> {
1734    let node = &current.representative;
1735    let visit_key = (node.file.clone(), node.symbol.clone());
1736    if visited.contains(&visit_key) {
1737        return Ok(StoreCallTreeNode {
1738            name: node.symbol.clone(),
1739            file: node.file.clone(),
1740            line: node.line,
1741            signature: node.signature.clone(),
1742            resolved: true,
1743            approximate: None,
1744            resolved_by: None,
1745            children: Vec::new(),
1746            depth_limited: false,
1747            truncated: 0,
1748        });
1749    }
1750    visited.insert(visit_key.clone());
1751
1752    let calls = forward_calls_for_nodes(store, &current.nodes)?;
1753    let mut children = Vec::new();
1754    let mut depth_limited = false;
1755    let mut truncated = 0usize;
1756
1757    if current_depth < max_depth {
1758        for call in calls {
1759            match call {
1760                ForwardCall::Resolved(site) => {
1761                    let resolved = resolve_exact_symbol(
1762                        store,
1763                        &site.target_file,
1764                        &site.target_symbol,
1765                        site.target.clone(),
1766                    )?;
1767                    if let Some(child_symbol) = resolved {
1768                        let mut child = call_tree_inner(
1769                            store,
1770                            &child_symbol,
1771                            max_depth,
1772                            current_depth + 1,
1773                            visited,
1774                        )?;
1775                        child.approximate = edge_approximate(&site);
1776                        child.resolved_by = edge_resolved_by(&site);
1777                        depth_limited |= child.depth_limited;
1778                        truncated += child.truncated;
1779                        children.push(child);
1780                    } else {
1781                        children.push(StoreCallTreeNode {
1782                            name: site.target_symbol.clone(),
1783                            file: site.target_file.clone(),
1784                            line: site.line,
1785                            signature: None,
1786                            resolved: false,
1787                            approximate: edge_approximate(&site),
1788                            resolved_by: edge_resolved_by(&site),
1789                            children: Vec::new(),
1790                            depth_limited: false,
1791                            truncated: 0,
1792                        });
1793                    }
1794                }
1795                ForwardCall::Unresolved(call) => children.push(StoreCallTreeNode {
1796                    name: call.symbol,
1797                    file: call.caller.file,
1798                    line: call.line,
1799                    signature: None,
1800                    resolved: false,
1801                    approximate: None,
1802                    resolved_by: None,
1803                    children: Vec::new(),
1804                    depth_limited: false,
1805                    truncated: 0,
1806                }),
1807            }
1808        }
1809    } else if !calls.is_empty() {
1810        depth_limited = true;
1811        truncated = calls.len();
1812    }
1813
1814    visited.remove(&visit_key);
1815    Ok(StoreCallTreeNode {
1816        name: node.symbol.clone(),
1817        file: node.file.clone(),
1818        line: node.line,
1819        signature: node.signature.clone(),
1820        resolved: true,
1821        approximate: None,
1822        resolved_by: None,
1823        children,
1824        depth_limited,
1825        truncated,
1826    })
1827}
1828
1829fn forward_calls_for_nodes(
1830    store: &impl CallGraphRead,
1831    nodes: &[StoreNode],
1832) -> StoreAdapterResult<Vec<ForwardCall>> {
1833    let mut calls = Vec::new();
1834    for node in nodes {
1835        calls.extend(
1836            store
1837                .outgoing_calls_of(node)?
1838                .into_iter()
1839                .map(ForwardCall::Resolved),
1840        );
1841        calls.extend(
1842            store
1843                .unresolved_calls_of(node)?
1844                .into_iter()
1845                .map(ForwardCall::Unresolved),
1846        );
1847    }
1848    calls.sort_by(|left, right| {
1849        left.byte_start()
1850            .cmp(&right.byte_start())
1851            .then(left.line().cmp(&right.line()))
1852    });
1853    let mut seen = BTreeSet::new();
1854    calls.retain(|call| seen.insert(call.call_site_key()));
1855    Ok(calls)
1856}
1857
1858fn forward_resolved_callees(
1859    store: &impl CallGraphRead,
1860    file: &str,
1861    symbol: &str,
1862) -> StoreAdapterResult<Vec<(StoreNode, EdgeMarker)>> {
1863    let Some(current) = resolve_exact_symbol(store, file, symbol, None)? else {
1864        return Ok(Vec::new());
1865    };
1866    let mut calls = Vec::new();
1867    for node in &current.nodes {
1868        calls.extend(store.outgoing_calls_of(node)?);
1869    }
1870    calls = dedup_call_sites(calls);
1871    calls.sort_by(|left, right| {
1872        left.byte_start
1873            .cmp(&right.byte_start)
1874            .then(left.line.cmp(&right.line))
1875    });
1876
1877    let mut callees = Vec::new();
1878    for site in calls {
1879        let resolved = resolve_exact_symbol(
1880            store,
1881            &site.target_file,
1882            &site.target_symbol,
1883            site.target.clone(),
1884        )?;
1885        if let Some(target) = resolved {
1886            callees.push((target.representative, edge_marker(&site)));
1887        }
1888    }
1889    Ok(callees)
1890}
1891
1892fn dedup_call_sites(sites: Vec<StoreCallSite>) -> Vec<StoreCallSite> {
1893    let mut seen = HashSet::new();
1894    let mut deduped = Vec::new();
1895    for site in sites {
1896        if seen.insert(call_site_key(&site)) {
1897            deduped.push(site);
1898        }
1899    }
1900    deduped
1901}
1902
1903fn dedup_call_site_count(sites: Vec<StoreCallSite>) -> usize {
1904    sites
1905        .into_iter()
1906        .map(|site| call_site_key(&site))
1907        .collect::<HashSet<_>>()
1908        .len()
1909}
1910
1911fn call_site_key(site: &StoreCallSite) -> (String, u32, String, String) {
1912    (
1913        site.caller.file.clone(),
1914        site.line,
1915        site.target_file.clone(),
1916        site.target_symbol.clone(),
1917    )
1918}
1919
1920fn trace_to_symbol_hop(node: &StoreNode) -> StoreTraceToSymbolHop {
1921    trace_to_symbol_hop_with_edge(node, EdgeMarker::default())
1922}
1923
1924fn trace_to_symbol_hop_with_edge(node: &StoreNode, edge: EdgeMarker) -> StoreTraceToSymbolHop {
1925    StoreTraceToSymbolHop {
1926        symbol: node.symbol.clone(),
1927        file: node.file.clone(),
1928        line: node.line,
1929        approximate: edge.approximate,
1930        resolved_by: edge.resolved_by,
1931    }
1932}
1933
1934fn trace_to_symbol_matches_target(
1935    file: &str,
1936    symbol: &str,
1937    to_symbol: &str,
1938    to_file: Option<&str>,
1939) -> bool {
1940    if !(symbol == to_symbol || unqualified_name(symbol) == to_symbol) {
1941        return false;
1942    }
1943    match to_file {
1944        Some(target_file) => file == target_file,
1945        None => true,
1946    }
1947}
1948
1949fn unqualified_name(symbol: &str) -> &str {
1950    symbol.rsplit("::").next().unwrap_or(symbol)
1951}
1952
1953fn read_source_line(path: &Path, line: u32) -> Option<String> {
1954    let source = std::fs::read_to_string(path).ok()?;
1955    source
1956        .lines()
1957        .nth(line.saturating_sub(1) as usize)
1958        .map(|line| line.trim().to_string())
1959}
1960
1961fn display_file_for_error(store: &impl CallGraphRead, file: &Path) -> String {
1962    absolute_file(store, file).display().to_string()
1963}
1964
1965fn relative_file(store: &impl CallGraphRead, file: &Path) -> String {
1966    let absolute = absolute_file(store, file);
1967    absolute
1968        .strip_prefix(store.project_root())
1969        .unwrap_or(&absolute)
1970        .to_string_lossy()
1971        .replace('\\', "/")
1972}
1973
1974fn absolute_file(store: &impl CallGraphRead, file: &Path) -> PathBuf {
1975    let full_path = if file.is_relative() {
1976        store.project_root().join(file)
1977    } else {
1978        file.to_path_buf()
1979    };
1980    std::fs::canonicalize(&full_path).unwrap_or(full_path)
1981}
1982
1983#[cfg(test)]
1984mod trace_to_tests {
1985    use super::*;
1986    use crate::callgraph_store::{
1987        Result as CallGraphResult, StoreCallersResult as RawCallersResult,
1988        StoreImpactResult as RawImpactResult, StoredEdge,
1989    };
1990    use std::cell::RefCell;
1991
1992    struct CountingStore {
1993        root: PathBuf,
1994        sqlite_path: PathBuf,
1995        nodes: HashMap<(String, String), StoreNode>,
1996        callers: HashMap<(String, String), Vec<StoreCallSite>>,
1997        caller_queries: RefCell<HashMap<(String, String), usize>>,
1998    }
1999
2000    impl CountingStore {
2001        fn new() -> Self {
2002            Self {
2003                root: PathBuf::from("/repo"),
2004                sqlite_path: PathBuf::from("/repo/callgraph.sqlite"),
2005                nodes: HashMap::new(),
2006                callers: HashMap::new(),
2007                caller_queries: RefCell::new(HashMap::new()),
2008            }
2009        }
2010
2011        fn add_node(&mut self, node: StoreNode) {
2012            self.nodes
2013                .insert((node.file.clone(), node.symbol.clone()), node);
2014        }
2015
2016        fn add_caller(&mut self, target: &StoreNode, caller: &StoreNode) {
2017            self.callers
2018                .entry((target.file.clone(), target.symbol.clone()))
2019                .or_default()
2020                .push(StoreCallSite {
2021                    caller: caller.clone(),
2022                    target_file: target.file.clone(),
2023                    target_symbol: target.symbol.clone(),
2024                    target: Some(target.clone()),
2025                    line: caller.line,
2026                    byte_start: 0,
2027                    byte_end: 1,
2028                    resolved: true,
2029                    provenance: TRACE_DATA_RESOLVER_PROVENANCE.to_string(),
2030                });
2031        }
2032
2033        fn total_caller_queries(&self) -> usize {
2034            self.caller_queries.borrow().values().sum()
2035        }
2036    }
2037
2038    impl CallGraphRead for CountingStore {
2039        fn project_root(&self) -> &Path {
2040            &self.root
2041        }
2042
2043        fn project_key(&self) -> &str {
2044            "test-project"
2045        }
2046
2047        fn sqlite_path(&self) -> &Path {
2048            &self.sqlite_path
2049        }
2050
2051        fn is_current(&self) -> bool {
2052            true
2053        }
2054
2055        fn edge_snapshot(&self) -> CallGraphResult<BTreeSet<StoredEdge>> {
2056            unreachable!("not used by trace_to_result")
2057        }
2058
2059        fn indexed_file_count(&self) -> CallGraphResult<usize> {
2060            Ok(self.nodes.len())
2061        }
2062
2063        fn node_for(&self, file_rel: &Path, symbol: &str) -> CallGraphResult<StoreNode> {
2064            Ok(self
2065                .nodes_for(file_rel, symbol)?
2066                .into_iter()
2067                .next()
2068                .expect("fixture node"))
2069        }
2070
2071        fn nodes_for(&self, file_rel: &Path, symbol: &str) -> CallGraphResult<Vec<StoreNode>> {
2072            let key = (
2073                file_rel.to_string_lossy().replace('\\', "/"),
2074                symbol.to_string(),
2075            );
2076            Ok(self.nodes.get(&key).cloned().into_iter().collect())
2077        }
2078
2079        fn nodes_matching(&self, symbol: &str) -> CallGraphResult<Vec<StoreNode>> {
2080            Ok(self
2081                .nodes
2082                .values()
2083                .filter(|node| node.symbol == symbol)
2084                .cloned()
2085                .collect())
2086        }
2087
2088        fn direct_callers_of(
2089            &self,
2090            file_rel: &Path,
2091            symbol: &str,
2092        ) -> CallGraphResult<Vec<StoreCallSite>> {
2093            let key = (
2094                file_rel.to_string_lossy().replace('\\', "/"),
2095                symbol.to_string(),
2096            );
2097            *self
2098                .caller_queries
2099                .borrow_mut()
2100                .entry(key.clone())
2101                .or_default() += 1;
2102            Ok(self.callers.get(&key).cloned().unwrap_or_default())
2103        }
2104
2105        fn callers_of(
2106            &self,
2107            _file_rel: &Path,
2108            _symbol: &str,
2109            _depth: usize,
2110        ) -> CallGraphResult<RawCallersResult> {
2111            unreachable!("not used by trace_to_result")
2112        }
2113
2114        fn impact_of(
2115            &self,
2116            _file_rel: &Path,
2117            _symbol: &str,
2118            _depth: usize,
2119        ) -> CallGraphResult<RawImpactResult> {
2120            unreachable!("not used by trace_to_result")
2121        }
2122
2123        fn outgoing_calls_of(&self, _node: &StoreNode) -> CallGraphResult<Vec<StoreCallSite>> {
2124            unreachable!("not used by trace_to_result")
2125        }
2126
2127        fn resolved_self_calls_of(&self, _node: &StoreNode) -> CallGraphResult<Vec<StoreCallSite>> {
2128            unreachable!("not used by trace_to_result")
2129        }
2130
2131        fn unresolved_calls_of(
2132            &self,
2133            _node: &StoreNode,
2134        ) -> CallGraphResult<Vec<StoreUnresolvedCall>> {
2135            unreachable!("not used by trace_to_result")
2136        }
2137
2138        fn call_tree(
2139            &self,
2140            _file_rel: &Path,
2141            _symbol: &str,
2142            _depth: usize,
2143        ) -> CallGraphResult<callgraph::CallTreeNode> {
2144            unreachable!("not used by trace_to_result")
2145        }
2146
2147        fn trace_to(
2148            &self,
2149            _file_rel: &Path,
2150            _symbol: &str,
2151            _max_depth: usize,
2152        ) -> CallGraphResult<callgraph::TraceToResult> {
2153            unreachable!("not used by trace_to_result")
2154        }
2155
2156        fn trace_to_symbol_candidates(
2157            &self,
2158            _to_symbol: &str,
2159        ) -> CallGraphResult<Vec<TraceToSymbolCandidate>> {
2160            unreachable!("not used by trace_to_result")
2161        }
2162
2163        fn trace_to_symbol(
2164            &self,
2165            _file_rel: &Path,
2166            _symbol: &str,
2167            _to_symbol: &str,
2168            _to_file: Option<&Path>,
2169            _max_depth: usize,
2170        ) -> CallGraphResult<callgraph::TraceToSymbolResult> {
2171            unreachable!("not used by trace_to_result")
2172        }
2173    }
2174
2175    fn node(symbol: &str, is_entry_point: bool) -> StoreNode {
2176        StoreNode::for_test(&format!("{symbol}.ts"), symbol, is_entry_point)
2177    }
2178
2179    fn layered_store(width: usize, layers: usize) -> (CountingStore, StoreNode) {
2180        let mut store = CountingStore::new();
2181        let target = node("target", false);
2182        store.add_node(target.clone());
2183        let mut previous = vec![target.clone()];
2184        for layer in 1..=layers {
2185            let current = (0..width)
2186                .map(|index| node(&format!("layer_{layer}_{index}"), layer == layers))
2187                .collect::<Vec<_>>();
2188            for caller in &current {
2189                store.add_node(caller.clone());
2190            }
2191            for target_node in &previous {
2192                for caller in &current {
2193                    store.add_caller(target_node, caller);
2194                }
2195            }
2196            previous = current;
2197        }
2198        (store, target)
2199    }
2200
2201    #[test]
2202    fn trace_to_caches_callers_for_convergent_path_prefixes() {
2203        let (store, target) = layered_store(2, 3);
2204
2205        let (result, expansions) = trace_to_result_with_budget(
2206            &store,
2207            Path::new(&target.file),
2208            &target.symbol,
2209            10,
2210            true,
2211            100,
2212        )
2213        .expect("trace result");
2214
2215        assert_eq!(result.total_paths, 8);
2216        assert!(!result.total_paths_is_lower_bound);
2217        assert_eq!(expansions, 15);
2218        assert_eq!(store.total_caller_queries(), 7);
2219        assert!(store
2220            .caller_queries
2221            .borrow()
2222            .values()
2223            .all(|queries| *queries == 1));
2224    }
2225
2226    #[test]
2227    fn trace_to_budget_returns_valid_paths_and_marks_counts_as_lower_bounds() {
2228        let (store, target) = layered_store(2, 3);
2229
2230        let (result, expansions) = trace_to_result_with_budget(
2231            &store,
2232            Path::new(&target.file),
2233            &target.symbol,
2234            10,
2235            true,
2236            10,
2237        )
2238        .expect("trace result");
2239
2240        assert!(result.total_paths_is_lower_bound);
2241        assert!(result.total_paths > 0);
2242        assert!(expansions <= 10);
2243        assert!(store.total_caller_queries() <= 10);
2244        let summary = result.hub_summary.expect("lower-bound summary");
2245        assert!(summary.counts_are_lower_bounds);
2246        assert!(summary.message.contains("at least"));
2247        for path in result.paths {
2248            assert!(path.hops.first().is_some_and(|hop| hop.is_entry_point));
2249            assert_eq!(
2250                path.hops.last().map(|hop| hop.symbol.as_str()),
2251                Some("target")
2252            );
2253        }
2254    }
2255
2256    #[test]
2257    fn trace_to_below_budget_preserves_exact_serialized_contract() {
2258        let mut store = CountingStore::new();
2259        let target = node("target", false);
2260        let middle = node("middle", false);
2261        let entry = node("entry", true);
2262        for fixture_node in [&target, &middle, &entry] {
2263            store.add_node(fixture_node.clone());
2264        }
2265        store.add_caller(&target, &middle);
2266        store.add_caller(&middle, &entry);
2267
2268        let (result, _expansions) = trace_to_result_with_budget(
2269            &store,
2270            Path::new(&target.file),
2271            &target.symbol,
2272            10,
2273            true,
2274            100,
2275        )
2276        .expect("trace result");
2277
2278        assert_eq!(
2279            serde_json::to_string(&result).expect("serialize trace result"),
2280            r#"{"target_symbol":"target","target_file":"target.ts","paths":[{"hops":[{"symbol":"entry","file":"entry.ts","line":1,"is_entry_point":true},{"symbol":"middle","file":"middle.ts","line":1,"is_entry_point":false},{"symbol":"target","file":"target.ts","line":1,"is_entry_point":false}]}],"total_paths":1,"entry_points_found":1,"max_depth_reached":false,"truncated_paths":1}"#
2281        );
2282    }
2283}