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::context::AppContext;
12use crate::edit::line_col_to_byte;
13use crate::error::AftError;
14use crate::inspect::job::is_test_file;
15use crate::parser::{
16    detect_language, extract_symbols_from_tree, grammar_for, FileParser, SharedSymbolCache,
17};
18use crate::protocol::Response;
19use crate::symbols::Symbol;
20
21pub type StoreAdapterResult<T> = Result<T, CallGraphStoreError>;
22
23const TRACE_DATA_RESOLVER_PROVENANCE: &str = "treesitter+resolver";
24const HUB_SUMMARY_THRESHOLD: usize = 20;
25const HUB_SUMMARY_LIMIT: usize = 15;
26// The agent only receives 15 representative paths once a trace becomes a hub. A
27// 10k expansion budget leaves ample room for ordinary traces while preventing a
28// layered call graph from unfolding millions of path prefixes synchronously.
29const TRACE_TO_EXPANSION_BUDGET: usize = 10_000;
30const TRACE_TO_RETAINED_PATH_LIMIT: usize = HUB_SUMMARY_LIMIT * 4;
31
32fn is_false(value: &bool) -> bool {
33    !*value
34}
35
36#[derive(Debug, Clone, Serialize)]
37pub struct StoreHubSummary {
38    pub message: String,
39    pub total: usize,
40    pub hidden_tests: usize,
41    pub shown: usize,
42    pub threshold: usize,
43    pub limit: usize,
44    #[serde(skip_serializing_if = "is_false")]
45    pub counts_are_lower_bounds: bool,
46}
47
48#[derive(Debug, Clone, Default)]
49struct EdgeMarker {
50    approximate: Option<bool>,
51    resolved_by: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize)]
55pub struct StoreCallersResult {
56    pub symbol: String,
57    pub file: String,
58    pub callers: Vec<StoreCallerGroup>,
59    pub total_callers: usize,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub hub_summary: Option<StoreHubSummary>,
62    pub scanned_files: usize,
63    pub depth_limited: bool,
64    pub truncated: usize,
65}
66
67#[derive(Debug, Clone, Serialize)]
68pub struct StoreCallerGroup {
69    pub file: String,
70    pub callers: Vec<StoreCallerEntry>,
71}
72
73#[derive(Debug, Clone, Serialize)]
74pub struct StoreCallerEntry {
75    pub symbol: String,
76    pub line: u32,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub approximate: Option<bool>,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub resolved_by: Option<String>,
81}
82
83#[derive(Debug, Clone, Serialize)]
84pub struct StoreCallTreeNode {
85    pub name: String,
86    pub file: String,
87    pub line: u32,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub signature: Option<String>,
90    pub resolved: bool,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub approximate: Option<bool>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub resolved_by: Option<String>,
95    pub children: Vec<StoreCallTreeNode>,
96    pub depth_limited: bool,
97    pub truncated: usize,
98}
99
100#[derive(Debug, Clone, Serialize)]
101pub struct StoreImpactResult {
102    pub symbol: String,
103    pub file: String,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub signature: Option<String>,
106    pub parameters: Vec<String>,
107    pub total_affected: usize,
108    pub affected_files: usize,
109    pub callers: Vec<StoreImpactCaller>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub hub_summary: Option<StoreHubSummary>,
112    pub depth_limited: bool,
113    pub truncated: usize,
114}
115
116#[derive(Debug, Clone, Serialize)]
117pub struct StoreImpactCaller {
118    pub caller_symbol: String,
119    pub caller_file: String,
120    pub line: u32,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub signature: Option<String>,
123    pub is_entry_point: bool,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub call_expression: Option<String>,
126    pub parameters: Vec<String>,
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub approximate: Option<bool>,
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub resolved_by: Option<String>,
131}
132
133#[derive(Debug, Clone, Serialize)]
134pub struct StoreTraceHop {
135    pub symbol: String,
136    pub file: String,
137    pub line: u32,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub signature: Option<String>,
140    pub is_entry_point: bool,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub approximate: Option<bool>,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub resolved_by: Option<String>,
145}
146
147#[derive(Debug, Clone, Serialize)]
148pub struct StoreTracePath {
149    pub hops: Vec<StoreTraceHop>,
150}
151
152#[derive(Debug, Clone, Serialize)]
153pub struct StoreTraceToResult {
154    pub target_symbol: String,
155    pub target_file: String,
156    pub paths: Vec<StoreTracePath>,
157    pub total_paths: usize,
158    #[serde(skip_serializing_if = "is_false")]
159    pub total_paths_is_lower_bound: bool,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub hub_summary: Option<StoreHubSummary>,
162    pub entry_points_found: usize,
163    pub max_depth_reached: bool,
164    pub truncated_paths: usize,
165}
166
167#[derive(Debug, Clone, Serialize)]
168pub struct StoreTraceToSymbolHop {
169    pub symbol: String,
170    pub file: String,
171    pub line: u32,
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub approximate: Option<bool>,
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub resolved_by: Option<String>,
176}
177
178#[derive(Debug, Clone, Serialize)]
179pub struct StoreTraceToSymbolResult {
180    pub path: Option<Vec<StoreTraceToSymbolHop>>,
181    pub complete: bool,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub reason: Option<String>,
184}
185
186#[derive(Clone)]
187enum ForwardCall {
188    Resolved(StoreCallSite),
189    Unresolved(StoreUnresolvedCall),
190}
191
192#[derive(Clone)]
193enum TraceForwardCall {
194    Resolved(StoreCallSite),
195    Unresolved(StoreUnresolvedCall),
196}
197
198impl TraceForwardCall {
199    fn byte_start(&self) -> usize {
200        match self {
201            Self::Resolved(site) => site.byte_start,
202            Self::Unresolved(call) => call.byte_start,
203        }
204    }
205
206    fn byte_end(&self) -> usize {
207        match self {
208            Self::Resolved(site) => site.byte_end,
209            Self::Unresolved(call) => call.byte_end,
210        }
211    }
212
213    fn line(&self) -> u32 {
214        match self {
215            Self::Resolved(site) => site.line,
216            Self::Unresolved(call) => call.line,
217        }
218    }
219
220    fn matches_position(&self, byte_start: usize, byte_end: usize) -> bool {
221        self.byte_start() == byte_start && self.byte_end() == byte_end
222    }
223}
224
225impl ForwardCall {
226    fn byte_start(&self) -> usize {
227        match self {
228            Self::Resolved(site) => site.byte_start,
229            Self::Unresolved(call) => call.byte_start,
230        }
231    }
232
233    fn line(&self) -> u32 {
234        match self {
235            Self::Resolved(site) => site.line,
236            Self::Unresolved(call) => call.line,
237        }
238    }
239
240    fn call_site_key(&self) -> (String, u32, String) {
241        match self {
242            Self::Resolved(site) => (
243                site.caller.file.clone(),
244                site.line,
245                format!("{}::{}", site.target_file, site.target_symbol),
246            ),
247            Self::Unresolved(call) => (call.caller.file.clone(), call.line, call.symbol.clone()),
248        }
249    }
250}
251
252#[derive(Clone)]
253struct ResolvedStoreSymbol {
254    representative: StoreNode,
255    nodes: Vec<StoreNode>,
256}
257
258#[derive(Clone)]
259struct TraceElem {
260    node: StoreNode,
261    edge: EdgeMarker,
262}
263
264fn edge_marker(site: &StoreCallSite) -> EdgeMarker {
265    if let Some(resolved_by) = site.supplemental_resolution() {
266        EdgeMarker {
267            approximate: Some(site.approximate()),
268            resolved_by: Some(resolved_by.to_string()),
269        }
270    } else {
271        EdgeMarker::default()
272    }
273}
274
275fn edge_approximate(site: &StoreCallSite) -> Option<bool> {
276    site.supplemental_resolution().map(|_| site.approximate())
277}
278
279fn edge_resolved_by(site: &StoreCallSite) -> Option<String> {
280    site.supplemental_resolution().map(ToString::to_string)
281}
282
283fn test_hidden_summary(
284    kind: &str,
285    total: usize,
286    hidden_tests: usize,
287    shown: usize,
288) -> StoreHubSummary {
289    StoreHubSummary {
290        message: format!(
291            "Next: {total} {kind} ({hidden_tests} in tests, hidden — pass includeTests) — narrow with scope"
292        ),
293        total,
294        hidden_tests,
295        shown,
296        threshold: HUB_SUMMARY_THRESHOLD,
297        limit: HUB_SUMMARY_LIMIT,
298        counts_are_lower_bounds: false,
299    }
300}
301
302fn included_summary(
303    kind: &str,
304    total: usize,
305    hidden_tests: usize,
306    shown: usize,
307) -> StoreHubSummary {
308    let test_note = if hidden_tests == 0 {
309        String::new()
310    } else {
311        format!(" ({hidden_tests} in tests, included)")
312    };
313    StoreHubSummary {
314        message: format!("Next: {total} {kind}{test_note} — showing {shown}; narrow with scope"),
315        total,
316        hidden_tests,
317        shown,
318        threshold: HUB_SUMMARY_THRESHOLD,
319        limit: HUB_SUMMARY_LIMIT,
320        counts_are_lower_bounds: false,
321    }
322}
323
324fn lower_bound_trace_summary(
325    total: usize,
326    hidden_tests: usize,
327    shown: usize,
328    include_tests: bool,
329) -> StoreHubSummary {
330    let test_note = if include_tests {
331        if hidden_tests == 0 {
332            " (test-path count also incomplete)".to_string()
333        } else {
334            format!(" (at least {hidden_tests} in tests, included)")
335        }
336    } else if hidden_tests == 0 {
337        " (additional test paths may be uncounted — pass includeTests)".to_string()
338    } else {
339        format!(" (at least {hidden_tests} in tests, hidden — pass includeTests)")
340    };
341    StoreHubSummary {
342        message: format!(
343            "Next: at least {total} paths{test_note} — showing {shown}; traversal capped; narrow with scope"
344        ),
345        total,
346        hidden_tests,
347        shown,
348        threshold: HUB_SUMMARY_THRESHOLD,
349        limit: HUB_SUMMARY_LIMIT,
350        counts_are_lower_bounds: true,
351    }
352}
353
354fn callsite_is_from_test(site: &StoreCallSite) -> bool {
355    is_test_file(&site.caller.file)
356}
357
358fn trace_path_starts_in_test(path: &StoreTracePath) -> bool {
359    path.hops.first().is_some_and(|hop| is_test_file(&hop.file))
360}
361
362fn dedup_sites_for_summary(sites: Vec<StoreCallSite>) -> Vec<StoreCallSite> {
363    let mut seen = BTreeSet::new();
364    sites
365        .into_iter()
366        .filter(|site| seen.insert((site.caller.symbol.clone(), site.target_symbol.clone())))
367        .collect()
368}
369
370fn trace_path_shape(path: &StoreTracePath) -> Vec<(String, String)> {
371    path.hops
372        .iter()
373        .map(|hop| (hop.file.clone(), hop.symbol.clone()))
374        .collect()
375}
376
377fn dedup_paths_for_summary(paths: Vec<StoreTracePath>) -> Vec<StoreTracePath> {
378    let mut seen = BTreeSet::new();
379    paths
380        .into_iter()
381        .filter(|path| seen.insert(trace_path_shape(path)))
382        .collect()
383}
384
385fn trace_path_order(left: &StoreTracePath, right: &StoreTracePath) -> std::cmp::Ordering {
386    let left_entry = left
387        .hops
388        .first()
389        .map(|hop| hop.symbol.as_str())
390        .unwrap_or("");
391    let right_entry = right
392        .hops
393        .first()
394        .map(|hop| hop.symbol.as_str())
395        .unwrap_or("");
396    left_entry
397        .cmp(right_entry)
398        .then(left.hops.len().cmp(&right.hops.len()))
399}
400
401fn store_trace_path(elems: &[TraceElem]) -> StoreTracePath {
402    let hops = elems
403        .iter()
404        .rev()
405        .enumerate()
406        .map(|(index, elem)| StoreTraceHop {
407            symbol: elem.node.symbol.clone(),
408            file: elem.node.file.clone(),
409            line: elem.node.line,
410            signature: elem.node.signature.clone(),
411            is_entry_point: index == 0 && elem.node.is_entry_point,
412            approximate: elem.edge.approximate,
413            resolved_by: elem.edge.resolved_by.clone(),
414        })
415        .collect();
416    StoreTracePath { hops }
417}
418
419fn retain_trace_path(retained: &mut Vec<StoreTracePath>, path: StoreTracePath) {
420    retained.push(path);
421    if retained.len() <= TRACE_TO_RETAINED_PATH_LIMIT {
422        return;
423    }
424    retained.sort_by(trace_path_order);
425    *retained = dedup_paths_for_summary(std::mem::take(retained))
426        .into_iter()
427        .take(TRACE_TO_RETAINED_PATH_LIMIT)
428        .collect();
429}
430
431fn filter_call_tree_tests(node: &mut StoreCallTreeNode) {
432    node.children.retain(|child| !is_test_file(&child.file));
433    for child in &mut node.children {
434        filter_call_tree_tests(child);
435    }
436}
437
438pub fn callers_result(
439    store: &impl CallGraphRead,
440    file: &Path,
441    symbol: &str,
442    depth: usize,
443    include_tests: bool,
444) -> StoreAdapterResult<StoreCallersResult> {
445    let target = resolve_symbol_query(store, file, symbol)?;
446    let effective_depth = depth.max(1);
447    let mut visited = HashSet::new();
448    let mut sites = Vec::new();
449    let mut depth_limited = false;
450    let mut truncated = 0usize;
451    let callers_by_symbol = prefetch_callers(
452        store,
453        &target.representative.file,
454        &target.representative.symbol,
455        effective_depth,
456    )?;
457
458    collect_callers_recursive(
459        store,
460        &callers_by_symbol,
461        &target.representative.file,
462        &target.representative.symbol,
463        effective_depth,
464        0,
465        &mut visited,
466        &mut sites,
467        &mut depth_limited,
468        &mut truncated,
469    )?;
470
471    let mut sites = dedup_call_sites(sites);
472    sites.sort_by(|left, right| {
473        left.caller
474            .file
475            .cmp(&right.caller.file)
476            .then(left.line.cmp(&right.line))
477            .then(left.caller.symbol.cmp(&right.caller.symbol))
478    });
479    let total_callers = sites.len();
480    let hidden_tests = sites
481        .iter()
482        .filter(|site| callsite_is_from_test(site))
483        .count();
484    let summarize = total_callers > HUB_SUMMARY_THRESHOLD;
485    let visible_sites = sites
486        .into_iter()
487        .filter(|site| include_tests || !callsite_is_from_test(site))
488        .collect::<Vec<_>>();
489    let visible_sites = if summarize {
490        dedup_sites_for_summary(visible_sites)
491            .into_iter()
492            .take(HUB_SUMMARY_LIMIT)
493            .collect::<Vec<_>>()
494    } else {
495        visible_sites
496    };
497    let hub_summary = if summarize {
498        Some(if include_tests {
499            included_summary("callers", total_callers, hidden_tests, visible_sites.len())
500        } else {
501            test_hidden_summary("callers", total_callers, hidden_tests, visible_sites.len())
502        })
503    } else {
504        None
505    };
506    let mut groups: BTreeMap<String, Vec<StoreCallerEntry>> = BTreeMap::new();
507    for site in visible_sites {
508        groups
509            .entry(site.caller.file.clone())
510            .or_default()
511            .push(StoreCallerEntry {
512                symbol: site.caller.symbol.clone(),
513                line: site.line,
514                approximate: edge_approximate(&site),
515                resolved_by: edge_resolved_by(&site),
516            });
517    }
518
519    Ok(StoreCallersResult {
520        symbol: target.representative.symbol,
521        file: target.representative.file,
522        callers: groups
523            .into_iter()
524            .map(|(file, callers)| StoreCallerGroup { file, callers })
525            .collect(),
526        total_callers,
527        hub_summary,
528        scanned_files: store.indexed_file_count()?,
529        depth_limited,
530        truncated,
531    })
532}
533
534pub fn call_tree_result(
535    store: &impl CallGraphRead,
536    file: &Path,
537    symbol: &str,
538    depth: usize,
539    include_tests: bool,
540) -> StoreAdapterResult<StoreCallTreeNode> {
541    let target = resolve_symbol_query(store, file, symbol)?;
542    let mut visited = HashSet::new();
543    let mut adjacency_cache = HashMap::new();
544    let mut tree = call_tree_inner(
545        store,
546        &target,
547        depth,
548        0,
549        &mut visited,
550        &mut adjacency_cache,
551        true,
552    )?;
553    if !include_tests {
554        filter_call_tree_tests(&mut tree);
555    }
556    Ok(tree)
557}
558
559pub fn impact_result(
560    store: &impl CallGraphRead,
561    file: &Path,
562    symbol: &str,
563    depth: usize,
564    include_tests: bool,
565) -> StoreAdapterResult<StoreImpactResult> {
566    let target = resolve_symbol_query(store, file, symbol)?;
567    let effective_depth = depth.max(1);
568    let mut visited = HashSet::new();
569    let mut sites = Vec::new();
570    let mut depth_limited = false;
571    let mut truncated = 0usize;
572    let callers_by_symbol = prefetch_callers(
573        store,
574        &target.representative.file,
575        &target.representative.symbol,
576        effective_depth,
577    )?;
578
579    collect_callers_recursive(
580        store,
581        &callers_by_symbol,
582        &target.representative.file,
583        &target.representative.symbol,
584        effective_depth,
585        0,
586        &mut visited,
587        &mut sites,
588        &mut depth_limited,
589        &mut truncated,
590    )?;
591
592    let mut sites = dedup_call_sites(sites);
593    sites.sort_by(|left, right| {
594        left.caller
595            .file
596            .cmp(&right.caller.file)
597            .then(left.line.cmp(&right.line))
598            .then(left.caller.symbol.cmp(&right.caller.symbol))
599    });
600    let total_affected = sites.len();
601    let hidden_tests = sites
602        .iter()
603        .filter(|site| callsite_is_from_test(site))
604        .count();
605    let summarize = total_affected > HUB_SUMMARY_THRESHOLD;
606    let affected_files = sites
607        .iter()
608        .map(|site| site.caller.file.clone())
609        .collect::<BTreeSet<_>>()
610        .len();
611    let visible_sites = sites
612        .into_iter()
613        .filter(|site| include_tests || !callsite_is_from_test(site))
614        .collect::<Vec<_>>();
615    let visible_sites = if summarize {
616        dedup_sites_for_summary(visible_sites)
617            .into_iter()
618            .take(HUB_SUMMARY_LIMIT)
619            .collect::<Vec<_>>()
620    } else {
621        visible_sites
622    };
623    let hub_summary = if summarize {
624        Some(if include_tests {
625            included_summary(
626                "affected callers",
627                total_affected,
628                hidden_tests,
629                visible_sites.len(),
630            )
631        } else {
632            test_hidden_summary(
633                "affected callers",
634                total_affected,
635                hidden_tests,
636                visible_sites.len(),
637            )
638        })
639    } else {
640        None
641    };
642    let target_signature = target.representative.signature.clone();
643    let target_parameters = target_signature
644        .as_deref()
645        .map(|signature| callgraph::extract_parameters(signature, target.representative.lang))
646        .unwrap_or_default();
647
648    let mut callers = Vec::new();
649    for site in visible_sites {
650        callers.push(StoreImpactCaller {
651            caller_symbol: site.caller.symbol.clone(),
652            caller_file: site.caller.file.clone(),
653            line: site.line,
654            signature: site.caller.signature.clone(),
655            is_entry_point: site.caller.is_entry_point,
656            call_expression: read_source_line(
657                &store.project_root().join(&site.caller.file),
658                site.line,
659            ),
660            parameters: site
661                .caller
662                .signature
663                .as_deref()
664                .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
665                .unwrap_or_default(),
666            approximate: edge_approximate(&site),
667            resolved_by: edge_resolved_by(&site),
668        });
669    }
670    callers.sort_by(|left, right| {
671        left.caller_file
672            .cmp(&right.caller_file)
673            .then(left.line.cmp(&right.line))
674    });
675
676    Ok(StoreImpactResult {
677        symbol: target.representative.symbol,
678        file: target.representative.file,
679        signature: target_signature,
680        parameters: target_parameters,
681        total_affected,
682        affected_files,
683        callers,
684        hub_summary,
685        depth_limited,
686        truncated,
687    })
688}
689
690pub fn trace_to_result(
691    store: &impl CallGraphRead,
692    file: &Path,
693    symbol: &str,
694    max_depth: usize,
695    include_tests: bool,
696) -> StoreAdapterResult<StoreTraceToResult> {
697    trace_to_result_with_budget(
698        store,
699        file,
700        symbol,
701        max_depth,
702        include_tests,
703        TRACE_TO_EXPANSION_BUDGET,
704    )
705    .map(|(result, _)| result)
706}
707
708fn trace_to_result_with_budget(
709    store: &impl CallGraphRead,
710    file: &Path,
711    symbol: &str,
712    max_depth: usize,
713    include_tests: bool,
714    expansion_budget: usize,
715) -> StoreAdapterResult<(StoreTraceToResult, usize)> {
716    let target = resolve_symbol_query(store, file, symbol)?;
717    let effective_max = if max_depth == 0 { 10 } else { max_depth };
718
719    let initial = vec![TraceElem {
720        node: target.representative.clone(),
721        edge: EdgeMarker::default(),
722    }];
723    let mut retained_paths = Vec::new();
724    let mut total_paths = 0usize;
725    let mut hidden_tests = 0usize;
726    if target.representative.is_entry_point {
727        total_paths = 1;
728        let path = store_trace_path(&initial);
729        if trace_path_starts_in_test(&path) {
730            hidden_tests = 1;
731        }
732        if include_tests || hidden_tests == 0 {
733            retain_trace_path(&mut retained_paths, path);
734        }
735    }
736
737    let mut queue = vec![(initial, 0usize)];
738    let mut max_depth_reached = false;
739    let mut truncated_paths = 0usize;
740    let mut expansions = 0usize;
741    let mut budget_exhausted = false;
742    let mut callers_by_symbol: HashMap<(String, String), Vec<StoreCallSite>> = HashMap::new();
743
744    'traversal: while let Some((path, depth)) = queue.pop() {
745        if expansions >= expansion_budget {
746            budget_exhausted = true;
747            break;
748        }
749        expansions += 1;
750        if depth >= effective_max {
751            max_depth_reached = true;
752            continue;
753        }
754        let Some(current) = path.last() else {
755            continue;
756        };
757        let caller_key = (current.node.file.clone(), current.node.symbol.clone());
758        if let std::collections::hash_map::Entry::Vacant(entry) =
759            callers_by_symbol.entry(caller_key.clone())
760        {
761            let callers =
762                dedup_call_sites(store.direct_callers_of(Path::new(&caller_key.0), &caller_key.1)?);
763            entry.insert(callers);
764        }
765        let callers = callers_by_symbol
766            .get(&caller_key)
767            .expect("trace caller cache populated above");
768        if callers.is_empty() {
769            if path.len() > 1 {
770                truncated_paths += 1;
771            }
772            continue;
773        }
774
775        let mut has_new_path = false;
776        for site in callers {
777            if path.iter().any(|elem| {
778                elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
779            }) {
780                continue;
781            }
782            has_new_path = true;
783            let mut next_path = path.clone();
784            if let Some(current) = next_path.last_mut() {
785                current.edge = edge_marker(&site);
786            }
787            next_path.push(TraceElem {
788                node: site.caller.clone(),
789                edge: EdgeMarker::default(),
790            });
791            if site.caller.is_entry_point {
792                total_paths = total_paths.saturating_add(1);
793                let completed = store_trace_path(&next_path);
794                let from_test = trace_path_starts_in_test(&completed);
795                if from_test {
796                    hidden_tests = hidden_tests.saturating_add(1);
797                }
798                if include_tests || !from_test {
799                    retain_trace_path(&mut retained_paths, completed);
800                }
801            }
802            // Reserve at most one future expansion slot per queued path. This
803            // bounds queue memory as well as the number of paths actually popped.
804            if expansions.saturating_add(queue.len()) >= expansion_budget {
805                budget_exhausted = true;
806                break 'traversal;
807            }
808            queue.push((next_path, depth + 1));
809        }
810        if !has_new_path && path.len() > 1 {
811            truncated_paths += 1;
812        }
813    }
814
815    retained_paths.sort_by(trace_path_order);
816    let summarize = budget_exhausted || total_paths > HUB_SUMMARY_THRESHOLD;
817    let paths = if summarize {
818        dedup_paths_for_summary(retained_paths)
819            .into_iter()
820            .take(HUB_SUMMARY_LIMIT)
821            .collect::<Vec<_>>()
822    } else {
823        retained_paths
824    };
825    let hub_summary = if summarize {
826        Some(if budget_exhausted {
827            lower_bound_trace_summary(total_paths, hidden_tests, paths.len(), include_tests)
828        } else if include_tests {
829            included_summary("paths", total_paths, hidden_tests, paths.len())
830        } else {
831            test_hidden_summary("paths", total_paths, hidden_tests, paths.len())
832        })
833    } else {
834        None
835    };
836
837    let entry_points_found = paths
838        .iter()
839        .filter_map(|path| path.hops.first())
840        .filter(|hop| hop.is_entry_point)
841        .map(|hop| (hop.file.clone(), hop.symbol.clone()))
842        .collect::<HashSet<_>>()
843        .len();
844
845    Ok((
846        StoreTraceToResult {
847            target_symbol: target.representative.symbol,
848            target_file: target.representative.file,
849            total_paths,
850            total_paths_is_lower_bound: budget_exhausted,
851            hub_summary,
852            paths,
853            entry_points_found,
854            max_depth_reached,
855            truncated_paths,
856        },
857        expansions,
858    ))
859}
860
861pub fn ensure_symbol_resolves(
862    store: &impl CallGraphRead,
863    file: &Path,
864    symbol: &str,
865) -> StoreAdapterResult<()> {
866    resolve_symbol_query(store, file, symbol).map(|_| ())
867}
868
869pub fn trace_to_symbol_candidates(
870    store: &impl CallGraphRead,
871    to_symbol: &str,
872) -> StoreAdapterResult<Vec<TraceToSymbolCandidate>> {
873    store.trace_to_symbol_candidates(to_symbol)
874}
875
876pub fn trace_to_symbol_result(
877    store: &impl CallGraphRead,
878    file: &Path,
879    symbol: &str,
880    to_symbol: &str,
881    to_file: Option<&Path>,
882    max_depth: usize,
883    include_tests: bool,
884) -> StoreAdapterResult<StoreTraceToSymbolResult> {
885    let origin = resolve_symbol_query(store, file, symbol)?;
886    let target_file = to_file.map(|path| relative_file(store, path));
887    let effective_max = if max_depth == 0 {
888        10
889    } else {
890        max_depth.min(16)
891    };
892
893    let start_hop = trace_to_symbol_hop(&origin.representative);
894    if trace_to_symbol_matches_target(
895        &origin.representative.file,
896        &origin.representative.symbol,
897        to_symbol,
898        target_file.as_deref(),
899    ) {
900        return Ok(StoreTraceToSymbolResult {
901            path: Some(vec![start_hop]),
902            complete: true,
903            reason: None,
904        });
905    }
906
907    let mut queue = VecDeque::new();
908    queue.push_back((
909        origin.representative.file.clone(),
910        origin.representative.symbol.clone(),
911        vec![start_hop],
912        0usize,
913    ));
914    let mut visited = HashSet::new();
915    visited.insert((
916        origin.representative.file.clone(),
917        origin.representative.symbol.clone(),
918    ));
919    let mut max_depth_exhausted = false;
920
921    while !queue.is_empty() {
922        let frontier_len = queue.len();
923        let frontier = queue
924            .iter()
925            .take(frontier_len)
926            .map(|(file, symbol, _, _)| (file.clone(), symbol.clone()))
927            .collect::<Vec<_>>();
928        let mut calls_by_symbol = store.outgoing_calls_for_symbols(&frontier)?;
929
930        // Process the fetched frontier in the original queue order. Later-discovered
931        // edges must not overtake shorter paths found by breadth-first traversal.
932        for _ in 0..frontier_len {
933            let Some((current_file, current_symbol, path, depth)) = queue.pop_front() else {
934                break;
935            };
936            let calls = calls_by_symbol
937                .remove(&(current_file, current_symbol))
938                .unwrap_or_default();
939            let callees = forward_resolved_callees(calls);
940
941            if depth >= effective_max {
942                if callees
943                    .iter()
944                    .any(|(node, _)| !visited.contains(&(node.file.clone(), node.symbol.clone())))
945                {
946                    max_depth_exhausted = true;
947                }
948                continue;
949            }
950
951            for (callee, edge) in callees {
952                if !include_tests && is_test_file(&callee.file) {
953                    continue;
954                }
955                if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
956                    continue;
957                }
958                let mut next_path = path.clone();
959                next_path.push(trace_to_symbol_hop_with_edge(&callee, edge));
960                if trace_to_symbol_matches_target(
961                    &callee.file,
962                    &callee.symbol,
963                    to_symbol,
964                    target_file.as_deref(),
965                ) {
966                    return Ok(StoreTraceToSymbolResult {
967                        path: Some(next_path),
968                        complete: true,
969                        reason: None,
970                    });
971                }
972                queue.push_back((callee.file, callee.symbol, next_path, depth + 1));
973            }
974        }
975    }
976
977    if max_depth_exhausted {
978        Ok(StoreTraceToSymbolResult {
979            path: None,
980            complete: false,
981            reason: Some("max_depth_exhausted".to_string()),
982        })
983    } else {
984        Ok(StoreTraceToSymbolResult {
985            path: None,
986            complete: true,
987            reason: Some("no_path_found".to_string()),
988        })
989    }
990}
991
992#[derive(Debug, Default)]
993struct TrackedBindings {
994    approximate_by_name: HashMap<String, bool>,
995}
996
997struct AssignmentInfo {
998    binding: String,
999    hop_variable: String,
1000    line: u32,
1001    approximate: bool,
1002    stop_after_hop: bool,
1003}
1004
1005impl TrackedBindings {
1006    fn with_origin(name: &str, approximate: bool) -> Self {
1007        let mut bindings = Self::default();
1008        bindings.track(name.to_string(), approximate);
1009        bindings
1010    }
1011
1012    fn contains(&self, name: &str) -> bool {
1013        self.approximate_by_name.contains_key(name)
1014    }
1015
1016    fn approximation(&self, name: &str) -> Option<bool> {
1017        self.approximate_by_name.get(name).copied()
1018    }
1019
1020    fn track(&mut self, name: String, approximate: bool) {
1021        self.approximate_by_name.insert(name, approximate);
1022    }
1023
1024    fn kill(&mut self, name: &str) {
1025        self.approximate_by_name.remove(name);
1026    }
1027
1028    fn mark_approximate(&mut self, name: &str) {
1029        if let Some(approximate) = self.approximate_by_name.get_mut(name) {
1030            *approximate = true;
1031        }
1032    }
1033}
1034
1035pub fn trace_data_result(
1036    store: &impl CallGraphRead,
1037    file: &Path,
1038    symbol: &str,
1039    expression: &str,
1040    max_depth: usize,
1041    symbol_cache: SharedSymbolCache,
1042) -> StoreAdapterResult<callgraph::TraceDataResult> {
1043    let origin_path = absolute_file(store, file);
1044    let origin_file = relative_file(store, &origin_path);
1045    let origin_symbol = resolve_symbol_query_with_cache(&origin_path, symbol, &symbol_cache)?;
1046
1047    let mut hops = Vec::new();
1048    let mut depth_limited = false;
1049    let mut visited = HashSet::new();
1050    trace_data_inner(
1051        store,
1052        &symbol_cache,
1053        &origin_path,
1054        &origin_symbol,
1055        expression,
1056        false,
1057        max_depth,
1058        0,
1059        &mut hops,
1060        &mut depth_limited,
1061        &mut visited,
1062    )?;
1063
1064    Ok(callgraph::TraceDataResult {
1065        expression: expression.to_string(),
1066        origin_file,
1067        origin_symbol,
1068        hops,
1069        depth_limited,
1070    })
1071}
1072
1073#[allow(clippy::too_many_arguments)]
1074fn trace_data_inner(
1075    store: &impl CallGraphRead,
1076    symbol_cache: &SharedSymbolCache,
1077    file: &Path,
1078    symbol: &str,
1079    tracking_name: &str,
1080    tracking_approximate: bool,
1081    max_depth: usize,
1082    current_depth: usize,
1083    hops: &mut Vec<callgraph::DataFlowHop>,
1084    depth_limited: &mut bool,
1085    visited: &mut HashSet<(String, String, String, bool)>,
1086) -> StoreAdapterResult<()> {
1087    let rel_file = relative_file(store, file);
1088    let visit_key = (
1089        rel_file.clone(),
1090        symbol.to_string(),
1091        tracking_name.to_string(),
1092        tracking_approximate,
1093    );
1094    if visited.contains(&visit_key) {
1095        return Ok(());
1096    }
1097    visited.insert(visit_key);
1098
1099    let current = resolve_exact_symbol(store, &rel_file, symbol, None)?
1100        .ok_or_else(|| CallGraphStoreError::StaleFiles(vec![rel_file.clone()]))?;
1101    let current_calls = trace_forward_calls_for_nodes(store, &current.nodes)?;
1102
1103    // Keep the legacy value-flow posture: parse the current source for body walks
1104    // and use the store only for cross-hop call facts.
1105    let source = std::fs::read_to_string(file)?;
1106    let Some(lang) = detect_language(file) else {
1107        return Ok(());
1108    };
1109    let grammar = grammar_for(lang);
1110    let mut parser = Parser::new();
1111    parser
1112        .set_language(&grammar)
1113        .map_err(|error| AftError::ParseError {
1114            message: format!("grammar init failed for {:?}: {}", lang, error),
1115        })?;
1116    let tree = parser
1117        .parse(&source, None)
1118        .ok_or_else(|| AftError::ParseError {
1119            message: format!("parse failed for {}", file.display()),
1120        })?;
1121    let symbols = extract_symbols_from_tree(&source, &tree, lang)?;
1122    let sym_info = symbols
1123        .iter()
1124        .find(|candidate| {
1125            symbol_identity_from_cache(candidate) == symbol || candidate.name == symbol
1126        })
1127        .ok_or_else(|| CallGraphStoreError::StaleFiles(vec![rel_file.clone()]))?;
1128
1129    let body_start = line_col_to_byte(&source, sym_info.range.start_line, sym_info.range.start_col);
1130    let body_end = line_col_to_byte(&source, sym_info.range.end_line, sym_info.range.end_col);
1131    let Some(body_node) = find_node_covering_range(tree.root_node(), body_start, body_end) else {
1132        return Ok(());
1133    };
1134
1135    let mut tracked = TrackedBindings::with_origin(tracking_name, tracking_approximate);
1136    walk_for_data_flow(
1137        store,
1138        symbol_cache,
1139        body_node,
1140        &source,
1141        &current_calls,
1142        &mut tracked,
1143        symbol,
1144        &rel_file,
1145        max_depth,
1146        current_depth,
1147        false,
1148        false,
1149        hops,
1150        depth_limited,
1151        visited,
1152    )
1153}
1154
1155#[allow(clippy::too_many_arguments)]
1156fn walk_for_data_flow(
1157    store: &impl CallGraphRead,
1158    symbol_cache: &SharedSymbolCache,
1159    node: Node<'_>,
1160    source: &str,
1161    current_calls: &[TraceForwardCall],
1162    tracked: &mut TrackedBindings,
1163    symbol: &str,
1164    rel_file: &str,
1165    max_depth: usize,
1166    current_depth: usize,
1167    control_flow_uncertain: bool,
1168    origin_function_seen: bool,
1169    hops: &mut Vec<callgraph::DataFlowHop>,
1170    depth_limited: &mut bool,
1171    visited: &mut HashSet<(String, String, String, bool)>,
1172) -> StoreAdapterResult<()> {
1173    let kind = node.kind();
1174    let is_var_decl = matches!(
1175        kind,
1176        "variable_declarator"
1177            | "assignment_expression"
1178            | "augmented_assignment_expression"
1179            | "assignment"
1180            | "let_declaration"
1181            | "short_var_declaration"
1182    );
1183
1184    if is_var_decl {
1185        if let Some(assignment) = extract_assignment_info(node, source, tracked) {
1186            let approximate = assignment.approximate || control_flow_uncertain;
1187            hops.push(callgraph::DataFlowHop {
1188                file: rel_file.to_string(),
1189                symbol: symbol.to_string(),
1190                variable: assignment.hop_variable,
1191                line: assignment.line,
1192                flow_type: "assignment".to_string(),
1193                approximate,
1194            });
1195            tracked.track(assignment.binding, approximate);
1196            if assignment.stop_after_hop {
1197                return Ok(());
1198            }
1199        } else if let Some(overwritten_name) = plain_assignment_target(node, source) {
1200            if tracked.contains(&overwritten_name) {
1201                if control_flow_uncertain {
1202                    // Because an assignment inside a conditional or loop may be skipped,
1203                    // retain the previously tracked path and mark subsequent uses as approximate.
1204                    tracked.mark_approximate(&overwritten_name);
1205                } else {
1206                    tracked.kill(&overwritten_name);
1207                }
1208            }
1209        }
1210    }
1211
1212    if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
1213        check_call_for_data_flow(
1214            store,
1215            symbol_cache,
1216            node,
1217            source,
1218            current_calls,
1219            tracked,
1220            symbol,
1221            rel_file,
1222            max_depth,
1223            current_depth,
1224            hops,
1225            depth_limited,
1226            visited,
1227        )?;
1228    }
1229
1230    let is_function = function_container(kind);
1231    let descendants_uncertain = control_flow_uncertain
1232        || conditional_flow_container(kind)
1233        || (is_function && origin_function_seen);
1234    let descendants_origin_function_seen = origin_function_seen || is_function;
1235    let mut cursor = node.walk();
1236    if cursor.goto_first_child() {
1237        loop {
1238            walk_for_data_flow(
1239                store,
1240                symbol_cache,
1241                cursor.node(),
1242                source,
1243                current_calls,
1244                tracked,
1245                symbol,
1246                rel_file,
1247                max_depth,
1248                current_depth,
1249                descendants_uncertain,
1250                descendants_origin_function_seen,
1251                hops,
1252                depth_limited,
1253                visited,
1254            )?;
1255            if !cursor.goto_next_sibling() {
1256                break;
1257            }
1258        }
1259    }
1260    Ok(())
1261}
1262
1263fn extract_assignment_info(
1264    node: Node<'_>,
1265    source: &str,
1266    tracked: &TrackedBindings,
1267) -> Option<AssignmentInfo> {
1268    let kind = node.kind();
1269    let line = node.start_position().row as u32 + 1;
1270
1271    let (name_node, value_node) = match kind {
1272        "variable_declarator" => (
1273            node.child_by_field_name("name")?,
1274            node.child_by_field_name("value")?,
1275        ),
1276        "assignment_expression" | "augmented_assignment_expression" | "assignment" => (
1277            node.child_by_field_name("left")?,
1278            node.child_by_field_name("right")?,
1279        ),
1280        "let_declaration" | "short_var_declaration" => (
1281            node.child_by_field_name("pattern")
1282                .or_else(|| node.child_by_field_name("left"))?,
1283            node.child_by_field_name("value")
1284                .or_else(|| node.child_by_field_name("right"))?,
1285        ),
1286        _ => return None,
1287    };
1288
1289    let binding = trace_node_text(name_node, source);
1290    if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
1291        tracked_reference_approximation(value_node, source, tracked)?;
1292        return Some(AssignmentInfo {
1293            binding: binding.clone(),
1294            hop_variable: binding,
1295            line,
1296            approximate: true,
1297            stop_after_hop: true,
1298        });
1299    }
1300
1301    let source_approximate = if kind == "augmented_assignment_expression" {
1302        merge_reference_approximation(
1303            tracked_reference_approximation(name_node, source, tracked),
1304            tracked_reference_approximation(value_node, source, tracked),
1305        )?
1306    } else {
1307        tracked_reference_approximation(value_node, source, tracked)?
1308    };
1309
1310    Some(AssignmentInfo {
1311        binding: binding.clone(),
1312        hop_variable: binding,
1313        line,
1314        approximate: source_approximate,
1315        stop_after_hop: false,
1316    })
1317}
1318
1319fn merge_reference_approximation(left: Option<bool>, right: Option<bool>) -> Option<bool> {
1320    match (left, right) {
1321        (Some(left), Some(right)) => Some(left && right),
1322        (Some(approximate), None) | (None, Some(approximate)) => Some(approximate),
1323        (None, None) => None,
1324    }
1325}
1326
1327fn tracked_reference_approximation(
1328    node: Node<'_>,
1329    source: &str,
1330    tracked: &TrackedBindings,
1331) -> Option<bool> {
1332    let mut approximation = if is_identifier_reference(node) {
1333        tracked.approximation(&trace_node_text(node, source))
1334    } else {
1335        None
1336    };
1337
1338    let mut cursor = node.walk();
1339    if cursor.goto_first_child() {
1340        loop {
1341            approximation = merge_reference_approximation(
1342                approximation,
1343                tracked_reference_approximation(cursor.node(), source, tracked),
1344            );
1345            if !cursor.goto_next_sibling() {
1346                break;
1347            }
1348        }
1349    }
1350    approximation
1351}
1352
1353fn is_identifier_reference(node: Node<'_>) -> bool {
1354    if !matches!(
1355        node.kind(),
1356        "identifier" | "simple_identifier" | "variable_name" | "shorthand_property_identifier"
1357    ) {
1358        return false;
1359    }
1360
1361    let Some(parent) = node.parent() else {
1362        return true;
1363    };
1364    !["property", "attribute", "field"].iter().any(|field| {
1365        parent
1366            .child_by_field_name(field)
1367            .is_some_and(|child| child.id() == node.id())
1368    })
1369}
1370
1371fn plain_assignment_target(node: Node<'_>, source: &str) -> Option<String> {
1372    if node.kind() == "augmented_assignment_expression" {
1373        return None;
1374    }
1375    let target = match node.kind() {
1376        "assignment_expression" | "assignment" => node.child_by_field_name("left")?,
1377        _ => return None,
1378    };
1379    is_identifier_reference(target).then(|| trace_node_text(target, source))
1380}
1381
1382fn conditional_flow_container(kind: &str) -> bool {
1383    matches!(
1384        kind,
1385        "if_statement"
1386            | "else_clause"
1387            | "conditional_expression"
1388            | "ternary_expression"
1389            | "switch_statement"
1390            | "switch_expression"
1391            | "match_expression"
1392            | "when_expression"
1393            | "for_statement"
1394            | "for_in_statement"
1395            | "for_each_statement"
1396            | "while_statement"
1397            | "do_statement"
1398            | "try_statement"
1399            | "catch_clause"
1400            | "finally_clause"
1401    )
1402}
1403
1404fn function_container(kind: &str) -> bool {
1405    matches!(
1406        kind,
1407        "function_declaration"
1408            | "function_expression"
1409            | "arrow_function"
1410            | "method_definition"
1411            | "lambda"
1412            | "lambda_expression"
1413            | "closure_expression"
1414    )
1415}
1416
1417#[allow(clippy::too_many_arguments)]
1418fn check_call_for_data_flow(
1419    store: &impl CallGraphRead,
1420    symbol_cache: &SharedSymbolCache,
1421    node: Node<'_>,
1422    source: &str,
1423    current_calls: &[TraceForwardCall],
1424    tracked: &TrackedBindings,
1425    symbol: &str,
1426    rel_file: &str,
1427    max_depth: usize,
1428    current_depth: usize,
1429    hops: &mut Vec<callgraph::DataFlowHop>,
1430    depth_limited: &mut bool,
1431    visited: &mut HashSet<(String, String, String, bool)>,
1432) -> StoreAdapterResult<()> {
1433    let args_node =
1434        find_child_by_kind(node, "arguments").or_else(|| find_child_by_kind(node, "argument_list"));
1435    let Some(args_node) = args_node else {
1436        return Ok(());
1437    };
1438
1439    let mut arg_positions = Vec::new();
1440    let mut arg_idx = 0usize;
1441    let mut cursor = args_node.walk();
1442    if cursor.goto_first_child() {
1443        loop {
1444            let child = cursor.node();
1445            let child_kind = child.kind();
1446            if child_kind == "(" || child_kind == ")" || child_kind == "," {
1447                if !cursor.goto_next_sibling() {
1448                    break;
1449                }
1450                continue;
1451            }
1452
1453            let arg_text = trace_node_text(child, source);
1454            if child_kind == "spread_element" || child_kind == "dictionary_splat" {
1455                if tracked_reference_approximation(child, source, tracked).is_some() {
1456                    hops.push(callgraph::DataFlowHop {
1457                        file: rel_file.to_string(),
1458                        symbol: symbol.to_string(),
1459                        variable: arg_text,
1460                        line: child.start_position().row as u32 + 1,
1461                        flow_type: "parameter".to_string(),
1462                        approximate: true,
1463                    });
1464                }
1465                if !cursor.goto_next_sibling() {
1466                    break;
1467                }
1468                arg_idx += 1;
1469                continue;
1470            }
1471
1472            if let Some(approximate) = tracked.approximation(&arg_text) {
1473                arg_positions.push((arg_idx, arg_text, approximate));
1474            }
1475
1476            arg_idx += 1;
1477            if !cursor.goto_next_sibling() {
1478                break;
1479            }
1480        }
1481    }
1482
1483    if arg_positions.is_empty() {
1484        return Ok(());
1485    }
1486
1487    let matched_call = current_calls
1488        .iter()
1489        .find(|call| call.matches_position(node.start_byte(), node.end_byte()));
1490
1491    match matched_call {
1492        Some(TraceForwardCall::Resolved(site)) => {
1493            let Some(target) = trace_target_node(store, site)? else {
1494                return Ok(());
1495            };
1496            if target.file != rel_file && current_depth + 1 > max_depth {
1497                *depth_limited = true;
1498                return Ok(());
1499            }
1500            let params = target
1501                .signature
1502                .as_deref()
1503                .map(|signature| callgraph::extract_parameters(signature, target.lang))
1504                .unwrap_or_default();
1505            let target_file = store.project_root().join(&target.file);
1506            for (pos, _tracked, approximate) in &arg_positions {
1507                if let Some(param_name) = params.get(*pos) {
1508                    hops.push(callgraph::DataFlowHop {
1509                        file: target.file.clone(),
1510                        symbol: target.symbol.clone(),
1511                        variable: param_name.clone(),
1512                        line: target.line,
1513                        flow_type: "parameter".to_string(),
1514                        approximate: *approximate,
1515                    });
1516                    trace_data_inner(
1517                        store,
1518                        symbol_cache,
1519                        &target_file,
1520                        &target.symbol,
1521                        param_name,
1522                        *approximate,
1523                        max_depth,
1524                        current_depth + 1,
1525                        hops,
1526                        depth_limited,
1527                        visited,
1528                    )?;
1529                }
1530            }
1531        }
1532        Some(TraceForwardCall::Unresolved(call)) => {
1533            push_unresolved_parameter_hops(hops, rel_file, &call.symbol, &arg_positions, node);
1534        }
1535        None => {
1536            let (_full_callee, short_callee) = extract_callee_names(node, source);
1537            if let Some(callee_name) = short_callee {
1538                push_unresolved_parameter_hops(hops, rel_file, &callee_name, &arg_positions, node);
1539            }
1540        }
1541    }
1542
1543    Ok(())
1544}
1545
1546fn push_unresolved_parameter_hops(
1547    hops: &mut Vec<callgraph::DataFlowHop>,
1548    rel_file: &str,
1549    callee_name: &str,
1550    arg_positions: &[(usize, String, bool)],
1551    call_node: Node<'_>,
1552) {
1553    for (_pos, tracked, _approximate) in arg_positions {
1554        hops.push(callgraph::DataFlowHop {
1555            file: rel_file.to_string(),
1556            symbol: callee_name.to_string(),
1557            variable: tracked.clone(),
1558            line: call_node.start_position().row as u32 + 1,
1559            flow_type: "parameter".to_string(),
1560            approximate: true,
1561        });
1562    }
1563}
1564
1565fn trace_target_node(
1566    store: &impl CallGraphRead,
1567    site: &StoreCallSite,
1568) -> StoreAdapterResult<Option<StoreNode>> {
1569    if let Some(target) = &site.target {
1570        return Ok(Some(target.clone()));
1571    }
1572    resolve_exact_symbol(store, &site.target_file, &site.target_symbol, None)
1573        .map(|resolved| resolved.map(|symbol| symbol.representative))
1574}
1575
1576fn trace_forward_calls_for_nodes(
1577    store: &impl CallGraphRead,
1578    nodes: &[StoreNode],
1579) -> StoreAdapterResult<Vec<TraceForwardCall>> {
1580    let mut calls = Vec::new();
1581    for node in nodes {
1582        calls.extend(
1583            store
1584                .outgoing_calls_of(node)?
1585                .into_iter()
1586                .filter(|site| site.resolved_by() == TRACE_DATA_RESOLVER_PROVENANCE)
1587                .map(TraceForwardCall::Resolved),
1588        );
1589        calls.extend(
1590            store
1591                .resolved_self_calls_of(node)?
1592                .into_iter()
1593                .filter(|site| site.resolved_by() == TRACE_DATA_RESOLVER_PROVENANCE)
1594                .map(TraceForwardCall::Resolved),
1595        );
1596        calls.extend(
1597            store
1598                .unresolved_calls_of(node)?
1599                .into_iter()
1600                .map(TraceForwardCall::Unresolved),
1601        );
1602    }
1603    calls.sort_by(|left, right| {
1604        left.byte_start()
1605            .cmp(&right.byte_start())
1606            .then(left.byte_end().cmp(&right.byte_end()))
1607            .then(left.line().cmp(&right.line()))
1608    });
1609    Ok(calls)
1610}
1611
1612fn resolve_symbol_query_with_cache(
1613    file: &Path,
1614    symbol: &str,
1615    symbol_cache: &SharedSymbolCache,
1616) -> StoreAdapterResult<String> {
1617    let mut parser = FileParser::with_symbol_cache(symbol_cache.clone());
1618    let symbols = parser.extract_symbols(file)?;
1619    let candidates = symbol_query_candidates_from_symbols(&symbols, symbol);
1620    match candidates.as_slice() {
1621        [candidate] => Ok(candidate.clone()),
1622        [] => Err(AftError::SymbolNotFound {
1623            name: symbol.to_string(),
1624            file: file.display().to_string(),
1625        }
1626        .into()),
1627        _ => Err(AftError::AmbiguousSymbol {
1628            name: symbol.to_string(),
1629            candidates,
1630        }
1631        .into()),
1632    }
1633}
1634
1635fn symbol_query_candidates_from_symbols(symbols: &[Symbol], symbol_name: &str) -> Vec<String> {
1636    let mut seen = HashSet::new();
1637    let mut candidates = Vec::new();
1638    let qualified_query = symbol_name.contains("::");
1639
1640    let mut consider = |candidate: String| {
1641        let matches = if qualified_query {
1642            candidate == symbol_name
1643        } else {
1644            candidate == symbol_name || unqualified_name(&candidate) == symbol_name
1645        };
1646        if matches && seen.insert(candidate.clone()) {
1647            candidates.push(candidate);
1648        }
1649    };
1650
1651    for symbol in symbols {
1652        consider(symbol_identity_from_cache(symbol));
1653        if symbol.exported {
1654            consider(symbol.name.clone());
1655        }
1656    }
1657
1658    candidates.sort();
1659    candidates
1660}
1661
1662fn symbol_identity_from_cache(symbol: &Symbol) -> String {
1663    if symbol.scope_chain.is_empty() {
1664        symbol.name.clone()
1665    } else {
1666        format!("{}::{}", symbol.scope_chain.join("::"), symbol.name)
1667    }
1668}
1669
1670fn trace_node_text(node: Node<'_>, source: &str) -> String {
1671    source[node.start_byte()..node.end_byte()].to_string()
1672}
1673
1674fn find_node_covering_range(root: Node<'_>, start: usize, end: usize) -> Option<Node<'_>> {
1675    let mut best = None;
1676    let mut cursor = root.walk();
1677
1678    fn walk_covering<'a>(
1679        cursor: &mut tree_sitter::TreeCursor<'a>,
1680        start: usize,
1681        end: usize,
1682        best: &mut Option<Node<'a>>,
1683    ) {
1684        let node = cursor.node();
1685        if node.start_byte() <= start && node.end_byte() >= end {
1686            *best = Some(node);
1687            if cursor.goto_first_child() {
1688                loop {
1689                    walk_covering(cursor, start, end, best);
1690                    if !cursor.goto_next_sibling() {
1691                        break;
1692                    }
1693                }
1694                cursor.goto_parent();
1695            }
1696        }
1697    }
1698
1699    walk_covering(&mut cursor, start, end, &mut best);
1700    best
1701}
1702
1703fn find_child_by_kind<'tree>(node: Node<'tree>, kind: &str) -> Option<Node<'tree>> {
1704    let mut cursor = node.walk();
1705    if cursor.goto_first_child() {
1706        loop {
1707            if cursor.node().kind() == kind {
1708                return Some(cursor.node());
1709            }
1710            if !cursor.goto_next_sibling() {
1711                break;
1712            }
1713        }
1714    }
1715    None
1716}
1717
1718fn extract_callee_names(node: Node<'_>, source: &str) -> (Option<String>, Option<String>) {
1719    let Some(callee) = node.child_by_field_name("function") else {
1720        return (None, None);
1721    };
1722    let full = trace_node_text(callee, source);
1723    let short = if full.contains('.') {
1724        full.rsplit('.').next().unwrap_or(&full).to_string()
1725    } else {
1726        full.clone()
1727    };
1728    (Some(full), Some(short))
1729}
1730
1731pub fn store_error_response(req_id: &str, operation: &str, error: CallGraphStoreError) -> Response {
1732    match error {
1733        CallGraphStoreError::Aft(error) => Response::error(req_id, error.code(), error.to_string()),
1734        CallGraphStoreError::Unavailable(message) => Response::error(
1735            req_id,
1736            "callgraph_unavailable",
1737            format!("{operation}: persisted callgraph store unavailable: {message}"),
1738        ),
1739        CallGraphStoreError::StaleFiles(files) => Response::error(
1740            req_id,
1741            "callgraph_stale",
1742            format!(
1743                "{operation}: persisted callgraph store has stale files: {}",
1744                files.join(", ")
1745            ),
1746        ),
1747        other => Response::error(
1748            req_id,
1749            "callgraph_store_error",
1750            format!("{operation}: persisted callgraph store error: {other}"),
1751        ),
1752    }
1753}
1754
1755pub fn note_callgraph_building(ctx: &crate::context::AppContext, tool: &str) {
1756    ctx.note_index_query(crate::logging::IndexPlane::Callgraph, tool, 0, "building");
1757}
1758
1759pub fn note_callgraph_served(
1760    ctx: &crate::context::AppContext,
1761    tool: &str,
1762    service_ms: u64,
1763    status: &str,
1764) {
1765    ctx.note_index_query(
1766        crate::logging::IndexPlane::Callgraph,
1767        tool,
1768        service_ms,
1769        status,
1770    );
1771}
1772
1773/// The persisted callgraph store is cold-building in the background. The op did
1774/// not block the request thread; the agent should retry shortly. Mirrors how
1775/// semantic search reports a build in progress.
1776pub fn building_response(req_id: &str, operation: &str) -> Response {
1777    Response::error(
1778        req_id,
1779        "callgraph_building",
1780        format!("{operation}: callgraph store is building in the background; retry shortly"),
1781    )
1782}
1783
1784/// A tripped breaker is terminal for this request. It deliberately does not
1785/// reuse `Unavailable`: the store is configured, but its builder was suspended.
1786pub fn suspended_response(
1787    req_id: &str,
1788    operation: &str,
1789    suspension: &crate::build_breaker::BuildSuspension,
1790) -> Response {
1791    suspended_response_at(
1792        req_id,
1793        operation,
1794        suspension,
1795        crate::callgraph_store::unix_millis_now(),
1796    )
1797}
1798
1799pub(crate) fn suspended_response_at(
1800    req_id: &str,
1801    operation: &str,
1802    suspension: &crate::build_breaker::BuildSuspension,
1803    now_ms: u64,
1804) -> Response {
1805    let age_ms = suspension.age_millis_at(now_ms);
1806    Response::error(
1807        req_id,
1808        "build_suspended",
1809        format!(
1810            "{operation}: build_suspended domain={} deaths={} age_ms={age_ms} reason={}; run doctor reset-build-breaker to resume",
1811            suspension.domain.as_str(),
1812            suspension.death_count,
1813            suspension.reason,
1814        ),
1815    )
1816}
1817
1818/// Return the terminal navigation response for a HOME-root callgraph store.
1819///
1820/// This is intentionally distinct from `callgraph_unavailable`: HOME is
1821/// configured successfully, but it is not a project root and must never enter
1822/// the cold-build/retry loop.
1823pub fn home_root_disabled_response(req_id: &str, operation: &str) -> Response {
1824    Response::error_with_data(
1825        req_id,
1826        "callgraph_disabled",
1827        format!(
1828            "{operation}: callgraph store is disabled for home roots; open a project subdirectory to enable it"
1829        ),
1830        serde_json::json!({
1831            "status": "disabled",
1832            "reason": "home_root",
1833        }),
1834    )
1835}
1836
1837pub fn unavailable_for(req_id: &str, operation: &str, ctx: &AppContext) -> Response {
1838    if ctx.is_home_root() {
1839        return home_root_disabled_response(req_id, operation);
1840    }
1841    unavailable_response(req_id, operation, ctx.is_worktree_bridge())
1842}
1843
1844pub fn unavailable_response(req_id: &str, operation: &str, worktree: bool) -> Response {
1845    let message = if worktree {
1846        format!(
1847            "{operation}: persisted callgraph store is unavailable in this read-only worktree; run a callgraph operation in the main checkout to build it first"
1848        )
1849    } else {
1850        format!("{operation}: project not configured — send 'configure' first")
1851    };
1852    let code = if worktree {
1853        "callgraph_unavailable"
1854    } else {
1855        "not_configured"
1856    };
1857    Response::error(req_id, code, message)
1858}
1859
1860fn resolve_symbol_query(
1861    store: &impl CallGraphRead,
1862    file: &Path,
1863    symbol: &str,
1864) -> StoreAdapterResult<ResolvedStoreSymbol> {
1865    let nodes = store.nodes_for(file, symbol)?;
1866    collapse_symbol_nodes(store, file, symbol, nodes)
1867}
1868
1869fn resolve_exact_symbol(
1870    store: &impl CallGraphRead,
1871    file: &str,
1872    symbol: &str,
1873    fallback: Option<StoreNode>,
1874) -> StoreAdapterResult<Option<ResolvedStoreSymbol>> {
1875    let nodes = store
1876        .nodes_for(Path::new(file), symbol)?
1877        .into_iter()
1878        .filter(|node| node.symbol == symbol)
1879        .collect::<Vec<_>>();
1880    if nodes.is_empty() {
1881        return Ok(fallback.map(|node| ResolvedStoreSymbol {
1882            representative: node.clone(),
1883            nodes: vec![node],
1884        }));
1885    }
1886    Ok(Some(collapse_exact_nodes(nodes)))
1887}
1888
1889fn collapse_symbol_nodes(
1890    store: &impl CallGraphRead,
1891    file: &Path,
1892    query: &str,
1893    nodes: Vec<StoreNode>,
1894) -> StoreAdapterResult<ResolvedStoreSymbol> {
1895    let mut by_symbol: BTreeMap<String, Vec<StoreNode>> = BTreeMap::new();
1896    for node in nodes {
1897        by_symbol.entry(node.symbol.clone()).or_default().push(node);
1898    }
1899
1900    match by_symbol.len() {
1901        0 => Err(CallGraphStoreError::Aft(AftError::SymbolNotFound {
1902            name: query.to_string(),
1903            file: display_file_for_error(store, file),
1904        })),
1905        1 => Ok(collapse_exact_nodes(
1906            by_symbol.into_values().next().unwrap_or_default(),
1907        )),
1908        _ => Err(CallGraphStoreError::Aft(AftError::AmbiguousSymbol {
1909            name: query.to_string(),
1910            candidates: by_symbol.into_keys().collect(),
1911        })),
1912    }
1913}
1914
1915fn collapse_exact_nodes(mut nodes: Vec<StoreNode>) -> ResolvedStoreSymbol {
1916    nodes.sort_by(|left, right| {
1917        left.symbol
1918            .cmp(&right.symbol)
1919            .then(left.line.cmp(&right.line))
1920            .then(left.end_line.cmp(&right.end_line))
1921    });
1922    let representative = nodes[0].clone();
1923    ResolvedStoreSymbol {
1924        representative,
1925        nodes,
1926    }
1927}
1928
1929fn prefetch_callers(
1930    store: &impl CallGraphRead,
1931    file: &str,
1932    symbol: &str,
1933    max_depth: usize,
1934) -> StoreAdapterResult<HashMap<(String, String), Vec<StoreCallSite>>> {
1935    let mut callers_by_symbol = HashMap::new();
1936    let mut frontier = BTreeSet::from([(file.to_string(), symbol.to_string())]);
1937
1938    for depth in 0..max_depth {
1939        let targets = frontier
1940            .into_iter()
1941            .filter(|target| !callers_by_symbol.contains_key(target))
1942            .collect::<Vec<_>>();
1943        if targets.is_empty() {
1944            break;
1945        }
1946        let fetched = store.direct_callers_for_symbols(&targets)?;
1947        let mut next_frontier = BTreeSet::new();
1948        if depth + 1 < max_depth {
1949            for site in fetched.values().flatten() {
1950                next_frontier.insert((site.caller.file.clone(), site.caller.symbol.clone()));
1951            }
1952        }
1953        callers_by_symbol.extend(fetched);
1954        frontier = next_frontier;
1955    }
1956
1957    Ok(callers_by_symbol)
1958}
1959
1960#[allow(clippy::too_many_arguments)]
1961fn collect_callers_recursive(
1962    store: &impl CallGraphRead,
1963    callers_by_symbol: &HashMap<(String, String), Vec<StoreCallSite>>,
1964    file: &str,
1965    symbol: &str,
1966    max_depth: usize,
1967    current_depth: usize,
1968    visited: &mut HashSet<(String, String)>,
1969    result: &mut Vec<StoreCallSite>,
1970    depth_limited: &mut bool,
1971    truncated: &mut usize,
1972) -> StoreAdapterResult<()> {
1973    if current_depth >= max_depth {
1974        let target = (file.to_string(), symbol.to_string());
1975        let counts = store.direct_caller_counts_of(std::slice::from_ref(&target))?;
1976        let omitted = counts.get(&target).copied().unwrap_or_default();
1977        if omitted > 0 {
1978            *depth_limited = true;
1979            *truncated += omitted;
1980        }
1981        return Ok(());
1982    }
1983
1984    if !visited.insert((file.to_string(), symbol.to_string())) {
1985        return Ok(());
1986    }
1987
1988    let Some(sites) = callers_by_symbol.get(&(file.to_string(), symbol.to_string())) else {
1989        return Ok(());
1990    };
1991    if sites.is_empty() {
1992        return Ok(());
1993    }
1994    if current_depth + 1 < max_depth {
1995        for site in sites {
1996            result.push(site.clone());
1997            collect_callers_recursive(
1998                store,
1999                callers_by_symbol,
2000                &site.caller.file,
2001                &site.caller.symbol,
2002                max_depth,
2003                current_depth + 1,
2004                visited,
2005                result,
2006                depth_limited,
2007                truncated,
2008            )?;
2009        }
2010    } else {
2011        let boundary_targets = sites
2012            .iter()
2013            .map(|site| (site.caller.file.clone(), site.caller.symbol.clone()))
2014            .collect::<BTreeSet<_>>()
2015            .into_iter()
2016            .collect::<Vec<_>>();
2017        let boundary_counts = store.direct_caller_counts_of(&boundary_targets)?;
2018        for site in sites {
2019            result.push(site.clone());
2020            let key = (site.caller.file.clone(), site.caller.symbol.clone());
2021            let omitted = boundary_counts.get(&key).copied().unwrap_or_default();
2022            if omitted > 0 {
2023                *depth_limited = true;
2024                *truncated += omitted;
2025            }
2026        }
2027    }
2028    Ok(())
2029}
2030
2031#[allow(clippy::too_many_arguments)]
2032fn call_tree_inner(
2033    store: &impl CallGraphRead,
2034    current: &ResolvedStoreSymbol,
2035    max_depth: usize,
2036    current_depth: usize,
2037    visited: &mut HashSet<(String, String)>,
2038    adjacency_cache: &mut HashMap<(String, String), Vec<ForwardCall>>,
2039    memoize_adjacency: bool,
2040) -> StoreAdapterResult<StoreCallTreeNode> {
2041    let node = &current.representative;
2042    let visit_key = (node.file.clone(), node.symbol.clone());
2043    if visited.contains(&visit_key) {
2044        return Ok(StoreCallTreeNode {
2045            name: node.symbol.clone(),
2046            file: node.file.clone(),
2047            line: node.line,
2048            signature: node.signature.clone(),
2049            resolved: true,
2050            approximate: None,
2051            resolved_by: None,
2052            children: Vec::new(),
2053            depth_limited: false,
2054            truncated: 0,
2055        });
2056    }
2057    visited.insert(visit_key.clone());
2058
2059    // Only adjacency rows are shared across converging paths. The visited set remains
2060    // path-local so repeated nodes still render independently with correct cycle guards.
2061    let calls = if memoize_adjacency {
2062        if let Some(calls) = adjacency_cache.get(&visit_key) {
2063            calls.clone()
2064        } else {
2065            let calls = forward_calls_for_nodes(store, &current.nodes)?;
2066            adjacency_cache.insert(visit_key.clone(), calls.clone());
2067            calls
2068        }
2069    } else {
2070        forward_calls_for_nodes(store, &current.nodes)?
2071    };
2072    let mut children = Vec::new();
2073    let mut depth_limited = false;
2074    let mut truncated = 0usize;
2075
2076    if current_depth < max_depth {
2077        for call in calls {
2078            match call {
2079                ForwardCall::Resolved(site) => {
2080                    let resolved = resolve_exact_symbol(
2081                        store,
2082                        &site.target_file,
2083                        &site.target_symbol,
2084                        site.target.clone(),
2085                    )?;
2086                    if let Some(child_symbol) = resolved {
2087                        let mut child = call_tree_inner(
2088                            store,
2089                            &child_symbol,
2090                            max_depth,
2091                            current_depth + 1,
2092                            visited,
2093                            adjacency_cache,
2094                            memoize_adjacency,
2095                        )?;
2096                        child.approximate = edge_approximate(&site);
2097                        child.resolved_by = edge_resolved_by(&site);
2098                        depth_limited |= child.depth_limited;
2099                        truncated += child.truncated;
2100                        children.push(child);
2101                    } else {
2102                        children.push(StoreCallTreeNode {
2103                            name: site.target_symbol.clone(),
2104                            file: site.target_file.clone(),
2105                            line: site.line,
2106                            signature: None,
2107                            resolved: false,
2108                            approximate: edge_approximate(&site),
2109                            resolved_by: edge_resolved_by(&site),
2110                            children: Vec::new(),
2111                            depth_limited: false,
2112                            truncated: 0,
2113                        });
2114                    }
2115                }
2116                ForwardCall::Unresolved(call) => children.push(StoreCallTreeNode {
2117                    name: call.symbol,
2118                    file: call.caller.file,
2119                    line: call.line,
2120                    signature: None,
2121                    resolved: false,
2122                    approximate: None,
2123                    resolved_by: None,
2124                    children: Vec::new(),
2125                    depth_limited: false,
2126                    truncated: 0,
2127                }),
2128            }
2129        }
2130    } else if !calls.is_empty() {
2131        depth_limited = true;
2132        truncated = calls.len();
2133    }
2134
2135    visited.remove(&visit_key);
2136    Ok(StoreCallTreeNode {
2137        name: node.symbol.clone(),
2138        file: node.file.clone(),
2139        line: node.line,
2140        signature: node.signature.clone(),
2141        resolved: true,
2142        approximate: None,
2143        resolved_by: None,
2144        children,
2145        depth_limited,
2146        truncated,
2147    })
2148}
2149
2150fn forward_calls_for_nodes(
2151    store: &impl CallGraphRead,
2152    nodes: &[StoreNode],
2153) -> StoreAdapterResult<Vec<ForwardCall>> {
2154    let mut calls = Vec::new();
2155    for node in nodes {
2156        calls.extend(
2157            store
2158                .outgoing_calls_of(node)?
2159                .into_iter()
2160                .map(ForwardCall::Resolved),
2161        );
2162        calls.extend(
2163            store
2164                .unresolved_calls_of(node)?
2165                .into_iter()
2166                .map(ForwardCall::Unresolved),
2167        );
2168    }
2169    calls.sort_by(|left, right| {
2170        left.byte_start()
2171            .cmp(&right.byte_start())
2172            .then(left.line().cmp(&right.line()))
2173    });
2174    let mut seen = BTreeSet::new();
2175    calls.retain(|call| seen.insert(call.call_site_key()));
2176    Ok(calls)
2177}
2178
2179fn forward_resolved_callees(calls: Vec<StoreCallSite>) -> Vec<(StoreNode, EdgeMarker)> {
2180    let mut calls = dedup_call_sites(calls);
2181    calls.sort_by(|left, right| {
2182        left.byte_start
2183            .cmp(&right.byte_start)
2184            .then(left.line.cmp(&right.line))
2185    });
2186
2187    calls
2188        .into_iter()
2189        .filter_map(|site| {
2190            let edge = edge_marker(&site);
2191            site.target.map(|target| (target, edge))
2192        })
2193        .collect()
2194}
2195
2196fn dedup_call_sites(sites: Vec<StoreCallSite>) -> Vec<StoreCallSite> {
2197    let mut seen = HashSet::new();
2198    let mut deduped = Vec::new();
2199    for site in sites {
2200        if seen.insert(call_site_key(&site)) {
2201            deduped.push(site);
2202        }
2203    }
2204    deduped
2205}
2206
2207#[cfg(test)]
2208fn dedup_call_site_count(sites: Vec<StoreCallSite>) -> usize {
2209    sites
2210        .into_iter()
2211        .map(|site| call_site_key(&site))
2212        .collect::<HashSet<_>>()
2213        .len()
2214}
2215
2216fn call_site_key(site: &StoreCallSite) -> (String, u32, String, String) {
2217    (
2218        site.caller.file.clone(),
2219        site.line,
2220        site.target_file.clone(),
2221        site.target_symbol.clone(),
2222    )
2223}
2224
2225fn trace_to_symbol_hop(node: &StoreNode) -> StoreTraceToSymbolHop {
2226    trace_to_symbol_hop_with_edge(node, EdgeMarker::default())
2227}
2228
2229fn trace_to_symbol_hop_with_edge(node: &StoreNode, edge: EdgeMarker) -> StoreTraceToSymbolHop {
2230    StoreTraceToSymbolHop {
2231        symbol: node.symbol.clone(),
2232        file: node.file.clone(),
2233        line: node.line,
2234        approximate: edge.approximate,
2235        resolved_by: edge.resolved_by,
2236    }
2237}
2238
2239fn trace_to_symbol_matches_target(
2240    file: &str,
2241    symbol: &str,
2242    to_symbol: &str,
2243    to_file: Option<&str>,
2244) -> bool {
2245    if !(symbol == to_symbol || unqualified_name(symbol) == to_symbol) {
2246        return false;
2247    }
2248    match to_file {
2249        Some(target_file) => file == target_file,
2250        None => true,
2251    }
2252}
2253
2254fn unqualified_name(symbol: &str) -> &str {
2255    symbol.rsplit("::").next().unwrap_or(symbol)
2256}
2257
2258fn read_source_line(path: &Path, line: u32) -> Option<String> {
2259    let source = std::fs::read_to_string(path).ok()?;
2260    source
2261        .lines()
2262        .nth(line.saturating_sub(1) as usize)
2263        .map(|line| line.trim().to_string())
2264}
2265
2266fn display_file_for_error(store: &impl CallGraphRead, file: &Path) -> String {
2267    absolute_file(store, file).display().to_string()
2268}
2269
2270fn relative_file(store: &impl CallGraphRead, file: &Path) -> String {
2271    let absolute = absolute_file(store, file);
2272    absolute
2273        .strip_prefix(store.project_root())
2274        .unwrap_or(&absolute)
2275        .to_string_lossy()
2276        .replace('\\', "/")
2277}
2278
2279fn absolute_file(store: &impl CallGraphRead, file: &Path) -> PathBuf {
2280    let full_path = if file.is_relative() {
2281        store.project_root().join(file)
2282    } else {
2283        file.to_path_buf()
2284    };
2285    std::fs::canonicalize(&full_path).unwrap_or(full_path)
2286}
2287
2288#[cfg(test)]
2289mod trace_to_tests {
2290    use super::*;
2291    use crate::callgraph_store::{
2292        Result as CallGraphResult, StoreCallersResult as RawCallersResult,
2293        StoreImpactResult as RawImpactResult, StoredEdge,
2294    };
2295    use std::cell::RefCell;
2296
2297    struct CountingStore {
2298        root: PathBuf,
2299        sqlite_path: PathBuf,
2300        nodes: HashMap<(String, String), StoreNode>,
2301        callers: HashMap<(String, String), Vec<StoreCallSite>>,
2302        outgoing: HashMap<(String, String), Vec<StoreCallSite>>,
2303        caller_queries: RefCell<HashMap<(String, String), usize>>,
2304        caller_frontier_query_count: RefCell<usize>,
2305        caller_frontier_target_count: RefCell<usize>,
2306        forward_query_count: RefCell<usize>,
2307        frontier_query_count: RefCell<usize>,
2308        caller_count_queries: RefCell<usize>,
2309        caller_count_targets: RefCell<usize>,
2310    }
2311
2312    impl CountingStore {
2313        fn new() -> Self {
2314            Self {
2315                root: PathBuf::from("/repo"),
2316                sqlite_path: PathBuf::from("/repo/callgraph.sqlite"),
2317                nodes: HashMap::new(),
2318                callers: HashMap::new(),
2319                outgoing: HashMap::new(),
2320                caller_queries: RefCell::new(HashMap::new()),
2321                caller_frontier_query_count: RefCell::new(0),
2322                caller_frontier_target_count: RefCell::new(0),
2323                forward_query_count: RefCell::new(0),
2324                frontier_query_count: RefCell::new(0),
2325                caller_count_queries: RefCell::new(0),
2326                caller_count_targets: RefCell::new(0),
2327            }
2328        }
2329
2330        fn add_node(&mut self, node: StoreNode) {
2331            self.nodes
2332                .insert((node.file.clone(), node.symbol.clone()), node);
2333        }
2334
2335        fn add_caller(&mut self, target: &StoreNode, caller: &StoreNode) {
2336            self.add_caller_at(target, caller, caller.line);
2337        }
2338
2339        fn add_caller_at(&mut self, target: &StoreNode, caller: &StoreNode, line: u32) {
2340            self.callers
2341                .entry((target.file.clone(), target.symbol.clone()))
2342                .or_default()
2343                .push(StoreCallSite {
2344                    caller: caller.clone(),
2345                    target_file: target.file.clone(),
2346                    target_symbol: target.symbol.clone(),
2347                    target: Some(target.clone()),
2348                    line,
2349                    byte_start: 0,
2350                    byte_end: 1,
2351                    resolved: true,
2352                    provenance: TRACE_DATA_RESOLVER_PROVENANCE.to_string(),
2353                });
2354        }
2355
2356        fn add_outgoing(&mut self, caller: &StoreNode, target: &StoreNode) {
2357            self.outgoing
2358                .entry((caller.file.clone(), caller.symbol.clone()))
2359                .or_default()
2360                .push(StoreCallSite {
2361                    caller: caller.clone(),
2362                    target_file: target.file.clone(),
2363                    target_symbol: target.symbol.clone(),
2364                    target: Some(target.clone()),
2365                    line: target.line,
2366                    byte_start: 0,
2367                    byte_end: 1,
2368                    resolved: true,
2369                    provenance: TRACE_DATA_RESOLVER_PROVENANCE.to_string(),
2370                });
2371        }
2372
2373        fn total_forward_queries(&self) -> usize {
2374            *self.forward_query_count.borrow()
2375        }
2376
2377        fn reset_forward_queries(&self) {
2378            *self.forward_query_count.borrow_mut() = 0;
2379        }
2380
2381        fn total_frontier_queries(&self) -> usize {
2382            *self.frontier_query_count.borrow()
2383        }
2384
2385        fn total_caller_queries(&self) -> usize {
2386            self.caller_queries.borrow().values().sum()
2387        }
2388
2389        fn total_caller_frontier_queries(&self) -> usize {
2390            *self.caller_frontier_query_count.borrow()
2391        }
2392
2393        fn caller_frontier_target_count(&self) -> usize {
2394            *self.caller_frontier_target_count.borrow()
2395        }
2396
2397        fn total_caller_count_queries(&self) -> usize {
2398            *self.caller_count_queries.borrow()
2399        }
2400
2401        fn caller_count_target_count(&self) -> usize {
2402            *self.caller_count_targets.borrow()
2403        }
2404
2405        fn reset_query_counts(&self) {
2406            self.caller_queries.borrow_mut().clear();
2407            *self.caller_frontier_query_count.borrow_mut() = 0;
2408            *self.caller_frontier_target_count.borrow_mut() = 0;
2409            *self.caller_count_queries.borrow_mut() = 0;
2410            *self.caller_count_targets.borrow_mut() = 0;
2411        }
2412    }
2413
2414    impl CallGraphRead for CountingStore {
2415        fn project_root(&self) -> &Path {
2416            &self.root
2417        }
2418
2419        fn project_key(&self) -> &str {
2420            "test-project"
2421        }
2422
2423        fn sqlite_path(&self) -> &Path {
2424            &self.sqlite_path
2425        }
2426
2427        fn is_current(&self) -> bool {
2428            true
2429        }
2430
2431        fn edge_snapshot(&self) -> CallGraphResult<BTreeSet<StoredEdge>> {
2432            unreachable!("not used by trace_to_result")
2433        }
2434
2435        fn indexed_file_count(&self) -> CallGraphResult<usize> {
2436            Ok(self.nodes.len())
2437        }
2438
2439        fn node_for(&self, file_rel: &Path, symbol: &str) -> CallGraphResult<StoreNode> {
2440            Ok(self
2441                .nodes_for(file_rel, symbol)?
2442                .into_iter()
2443                .next()
2444                .expect("fixture node"))
2445        }
2446
2447        fn nodes_for(&self, file_rel: &Path, symbol: &str) -> CallGraphResult<Vec<StoreNode>> {
2448            let key = (
2449                file_rel.to_string_lossy().replace('\\', "/"),
2450                symbol.to_string(),
2451            );
2452            Ok(self.nodes.get(&key).cloned().into_iter().collect())
2453        }
2454
2455        fn nodes_matching(&self, symbol: &str) -> CallGraphResult<Vec<StoreNode>> {
2456            Ok(self
2457                .nodes
2458                .values()
2459                .filter(|node| node.symbol == symbol)
2460                .cloned()
2461                .collect())
2462        }
2463
2464        fn direct_callers_of(
2465            &self,
2466            file_rel: &Path,
2467            symbol: &str,
2468        ) -> CallGraphResult<Vec<StoreCallSite>> {
2469            let key = (
2470                file_rel.to_string_lossy().replace('\\', "/"),
2471                symbol.to_string(),
2472            );
2473            *self
2474                .caller_queries
2475                .borrow_mut()
2476                .entry(key.clone())
2477                .or_default() += 1;
2478            Ok(self.callers.get(&key).cloned().unwrap_or_default())
2479        }
2480
2481        fn direct_callers_for_symbols(
2482            &self,
2483            targets: &[(String, String)],
2484        ) -> CallGraphResult<HashMap<(String, String), Vec<StoreCallSite>>> {
2485            *self.caller_frontier_query_count.borrow_mut() += 1;
2486            *self.caller_frontier_target_count.borrow_mut() += targets.len();
2487            Ok(targets
2488                .iter()
2489                .cloned()
2490                .map(|target| {
2491                    let callers = self.callers.get(&target).cloned().unwrap_or_default();
2492                    (target, callers)
2493                })
2494                .collect())
2495        }
2496
2497        fn direct_caller_counts_of(
2498            &self,
2499            targets: &[(String, String)],
2500        ) -> CallGraphResult<HashMap<(String, String), usize>> {
2501            *self.caller_count_queries.borrow_mut() += 1;
2502            *self.caller_count_targets.borrow_mut() = targets.len();
2503            Ok(targets
2504                .iter()
2505                .cloned()
2506                .map(|target| {
2507                    let count = self
2508                        .callers
2509                        .get(&target)
2510                        .cloned()
2511                        .map(dedup_call_site_count)
2512                        .unwrap_or_default();
2513                    (target, count)
2514                })
2515                .collect())
2516        }
2517
2518        fn callers_of(
2519            &self,
2520            _file_rel: &Path,
2521            _symbol: &str,
2522            _depth: usize,
2523        ) -> CallGraphResult<RawCallersResult> {
2524            unreachable!("not used by trace_to_result")
2525        }
2526
2527        fn impact_of(
2528            &self,
2529            _file_rel: &Path,
2530            _symbol: &str,
2531            _depth: usize,
2532        ) -> CallGraphResult<RawImpactResult> {
2533            unreachable!("not used by trace_to_result")
2534        }
2535
2536        fn outgoing_calls_of(&self, node: &StoreNode) -> CallGraphResult<Vec<StoreCallSite>> {
2537            *self.forward_query_count.borrow_mut() += 1;
2538            Ok(self
2539                .outgoing
2540                .get(&(node.file.clone(), node.symbol.clone()))
2541                .cloned()
2542                .unwrap_or_default())
2543        }
2544
2545        fn outgoing_calls_for_symbols(
2546            &self,
2547            sources: &[(String, String)],
2548        ) -> CallGraphResult<HashMap<(String, String), Vec<StoreCallSite>>> {
2549            *self.frontier_query_count.borrow_mut() += 1;
2550            Ok(sources
2551                .iter()
2552                .cloned()
2553                .map(|source| {
2554                    let calls = self.outgoing.get(&source).cloned().unwrap_or_default();
2555                    (source, calls)
2556                })
2557                .collect())
2558        }
2559
2560        fn resolved_self_calls_of(&self, _node: &StoreNode) -> CallGraphResult<Vec<StoreCallSite>> {
2561            unreachable!("not used by these adapter tests")
2562        }
2563
2564        fn unresolved_calls_of(
2565            &self,
2566            _node: &StoreNode,
2567        ) -> CallGraphResult<Vec<StoreUnresolvedCall>> {
2568            *self.forward_query_count.borrow_mut() += 1;
2569            Ok(Vec::new())
2570        }
2571
2572        fn call_tree(
2573            &self,
2574            _file_rel: &Path,
2575            _symbol: &str,
2576            _depth: usize,
2577        ) -> CallGraphResult<callgraph::CallTreeNode> {
2578            unreachable!("not used by trace_to_result")
2579        }
2580
2581        fn trace_to(
2582            &self,
2583            _file_rel: &Path,
2584            _symbol: &str,
2585            _max_depth: usize,
2586        ) -> CallGraphResult<callgraph::TraceToResult> {
2587            unreachable!("not used by trace_to_result")
2588        }
2589
2590        fn trace_to_symbol_candidates(
2591            &self,
2592            _to_symbol: &str,
2593        ) -> CallGraphResult<Vec<TraceToSymbolCandidate>> {
2594            unreachable!("not used by trace_to_result")
2595        }
2596
2597        fn trace_to_symbol(
2598            &self,
2599            _file_rel: &Path,
2600            _symbol: &str,
2601            _to_symbol: &str,
2602            _to_file: Option<&Path>,
2603            _max_depth: usize,
2604        ) -> CallGraphResult<callgraph::TraceToSymbolResult> {
2605            unreachable!("not used by trace_to_result")
2606        }
2607    }
2608
2609    fn node(symbol: &str, is_entry_point: bool) -> StoreNode {
2610        StoreNode::for_test(&format!("{symbol}.ts"), symbol, is_entry_point)
2611    }
2612
2613    fn layered_store(width: usize, layers: usize) -> (CountingStore, StoreNode) {
2614        let mut store = CountingStore::new();
2615        let target = node("target", false);
2616        store.add_node(target.clone());
2617        let mut previous = vec![target.clone()];
2618        for layer in 1..=layers {
2619            let current = (0..width)
2620                .map(|index| node(&format!("layer_{layer}_{index}"), layer == layers))
2621                .collect::<Vec<_>>();
2622            for caller in &current {
2623                store.add_node(caller.clone());
2624            }
2625            for target_node in &previous {
2626                for caller in &current {
2627                    store.add_caller(target_node, caller);
2628                }
2629            }
2630            previous = current;
2631        }
2632        (store, target)
2633    }
2634
2635    fn converging_call_tree_store(width: usize) -> (CountingStore, StoreNode) {
2636        let mut store = CountingStore::new();
2637        let root = node("root", false);
2638        let helper = node("helper", false);
2639        let leaf = node("leaf", false);
2640        for fixture_node in [&root, &helper, &leaf] {
2641            store.add_node(fixture_node.clone());
2642        }
2643        store.add_outgoing(&helper, &leaf);
2644
2645        for index in 0..width {
2646            let handler = node(&format!("handler_{index}"), false);
2647            store.add_node(handler.clone());
2648            store.add_outgoing(&root, &handler);
2649            store.add_outgoing(&handler, &helper);
2650        }
2651        (store, root)
2652    }
2653
2654    fn call_tree_node_count(tree: &StoreCallTreeNode) -> usize {
2655        1 + tree
2656            .children
2657            .iter()
2658            .map(call_tree_node_count)
2659            .sum::<usize>()
2660    }
2661
2662    #[test]
2663    fn call_tree_memoizes_only_adjacency_and_preserves_rendered_tree() {
2664        let (store, root) = converging_call_tree_store(200);
2665
2666        let memoized = call_tree_result(&store, Path::new(&root.file), &root.symbol, 3, true)
2667            .expect("memoized call tree");
2668        let memoized_queries = store.total_forward_queries();
2669
2670        store.reset_forward_queries();
2671        let resolved_root = ResolvedStoreSymbol {
2672            representative: root.clone(),
2673            nodes: vec![root],
2674        };
2675        let mut visited = HashSet::new();
2676        let mut unused_cache = HashMap::new();
2677        let uncached = call_tree_inner(
2678            &store,
2679            &resolved_root,
2680            3,
2681            0,
2682            &mut visited,
2683            &mut unused_cache,
2684            false,
2685        )
2686        .expect("uncached call tree");
2687        let uncached_queries = store.total_forward_queries();
2688
2689        assert_eq!(call_tree_node_count(&memoized), 601);
2690        assert_eq!(
2691            serde_json::to_vec(&memoized).expect("serialize memoized tree"),
2692            serde_json::to_vec(&uncached).expect("serialize uncached tree"),
2693            "adjacency memoization must not change rendered call-tree bytes"
2694        );
2695        assert_eq!(uncached_queries, 1_202);
2696        assert_eq!(memoized_queries, 406);
2697    }
2698
2699    #[test]
2700    fn reverse_frontiers_batch_caller_fetches_by_depth() {
2701        let (store, target) = layered_store(20, 6);
2702
2703        let result = callers_result(&store, Path::new(&target.file), &target.symbol, 5, true)
2704            .expect("callers result");
2705
2706        assert_eq!(store.total_caller_queries(), 0);
2707        assert_eq!(store.total_caller_frontier_queries(), 5);
2708        assert_eq!(store.caller_frontier_target_count(), 81);
2709        assert_eq!(result.total_callers, 1_620);
2710        assert_eq!(result.callers.len(), 15);
2711        assert!(result.depth_limited);
2712    }
2713
2714    #[test]
2715    fn batched_boundary_counts_preserve_serialized_callers_and_impact_contract() {
2716        let mut store = CountingStore::new();
2717        let target = node("target", false);
2718        let boundary = node("hubCaller", false);
2719        let upstream_a = node("upstreamA", true);
2720        let upstream_b = node("upstreamB", true);
2721        for fixture_node in [&target, &boundary, &upstream_a, &upstream_b] {
2722            store.add_node(fixture_node.clone());
2723        }
2724        for line in 1..=21 {
2725            store.add_caller_at(&target, &boundary, line);
2726        }
2727        store.add_caller(&boundary, &upstream_a);
2728        store.add_caller(&boundary, &upstream_b);
2729
2730        let callers = callers_result(&store, Path::new(&target.file), &target.symbol, 1, true)
2731            .expect("callers result");
2732        assert_eq!(store.total_caller_queries(), 0);
2733        assert_eq!(store.total_caller_frontier_queries(), 1);
2734        assert_eq!(store.caller_frontier_target_count(), 1);
2735        assert_eq!(store.total_caller_count_queries(), 1);
2736        assert_eq!(store.caller_count_target_count(), 1);
2737        assert_eq!(
2738            serde_json::to_string(&callers).expect("serialize callers result"),
2739            r#"{"symbol":"target","file":"target.ts","callers":[{"file":"hubCaller.ts","callers":[{"symbol":"hubCaller","line":1}]}],"total_callers":21,"hub_summary":{"message":"Next: 21 callers — showing 1; narrow with scope","total":21,"hidden_tests":0,"shown":1,"threshold":20,"limit":15},"scanned_files":4,"depth_limited":true,"truncated":42}"#
2740        );
2741
2742        store.reset_query_counts();
2743        let impact = impact_result(&store, Path::new(&target.file), &target.symbol, 1, true)
2744            .expect("impact result");
2745        assert_eq!(store.total_caller_queries(), 0);
2746        assert_eq!(store.total_caller_frontier_queries(), 1);
2747        assert_eq!(store.caller_frontier_target_count(), 1);
2748        assert_eq!(store.total_caller_count_queries(), 1);
2749        assert_eq!(store.caller_count_target_count(), 1);
2750        assert_eq!(
2751            serde_json::to_string(&impact).expect("serialize impact result"),
2752            r#"{"symbol":"target","file":"target.ts","parameters":[],"total_affected":21,"affected_files":1,"callers":[{"caller_symbol":"hubCaller","caller_file":"hubCaller.ts","line":1,"is_entry_point":false,"parameters":[]}],"hub_summary":{"message":"Next: 21 affected callers — showing 1; narrow with scope","total":21,"hidden_tests":0,"shown":1,"threshold":20,"limit":15},"depth_limited":true,"truncated":42}"#
2753        );
2754    }
2755
2756    #[test]
2757    fn trace_to_symbol_batches_frontiers_without_changing_shortest_path() {
2758        let mut store = CountingStore::new();
2759        let origin = node("origin", false);
2760        let slow = node("slow", false);
2761        let slow_middle = node("slowMiddle", false);
2762        let fast = node("fast", false);
2763        let target = node("target", false);
2764        for fixture_node in [&origin, &slow, &slow_middle, &fast, &target] {
2765            store.add_node(fixture_node.clone());
2766        }
2767        store.add_outgoing(&origin, &slow);
2768        store.add_outgoing(&origin, &fast);
2769        store.add_outgoing(&slow, &slow_middle);
2770        store.add_outgoing(&slow_middle, &target);
2771        store.add_outgoing(&fast, &target);
2772
2773        let result = trace_to_symbol_result(
2774            &store,
2775            Path::new(&origin.file),
2776            &origin.symbol,
2777            &target.symbol,
2778            None,
2779            10,
2780            true,
2781        )
2782        .expect("trace-to-symbol result");
2783        let symbols = result
2784            .path
2785            .expect("shortest path")
2786            .into_iter()
2787            .map(|hop| hop.symbol)
2788            .collect::<Vec<_>>();
2789
2790        assert_eq!(symbols, vec!["origin", "fast", "target"]);
2791        assert_eq!(store.total_frontier_queries(), 2);
2792        assert_eq!(store.total_forward_queries(), 0);
2793    }
2794
2795    #[test]
2796    fn trace_to_caches_callers_for_convergent_path_prefixes() {
2797        let (store, target) = layered_store(2, 3);
2798
2799        let (result, expansions) = trace_to_result_with_budget(
2800            &store,
2801            Path::new(&target.file),
2802            &target.symbol,
2803            10,
2804            true,
2805            100,
2806        )
2807        .expect("trace result");
2808
2809        assert_eq!(result.total_paths, 8);
2810        assert!(!result.total_paths_is_lower_bound);
2811        assert_eq!(expansions, 15);
2812        assert_eq!(store.total_caller_queries(), 7);
2813        assert!(store
2814            .caller_queries
2815            .borrow()
2816            .values()
2817            .all(|queries| *queries == 1));
2818    }
2819
2820    #[test]
2821    fn trace_to_budget_returns_valid_paths_and_marks_counts_as_lower_bounds() {
2822        let (store, target) = layered_store(2, 3);
2823
2824        let (result, expansions) = trace_to_result_with_budget(
2825            &store,
2826            Path::new(&target.file),
2827            &target.symbol,
2828            10,
2829            true,
2830            10,
2831        )
2832        .expect("trace result");
2833
2834        assert!(result.total_paths_is_lower_bound);
2835        assert!(result.total_paths > 0);
2836        assert!(expansions <= 10);
2837        assert!(store.total_caller_queries() <= 10);
2838        let summary = result.hub_summary.expect("lower-bound summary");
2839        assert!(summary.counts_are_lower_bounds);
2840        assert!(summary.message.contains("at least"));
2841        for path in result.paths {
2842            assert!(path.hops.first().is_some_and(|hop| hop.is_entry_point));
2843            assert_eq!(
2844                path.hops.last().map(|hop| hop.symbol.as_str()),
2845                Some("target")
2846            );
2847        }
2848    }
2849
2850    #[test]
2851    fn trace_to_below_budget_preserves_exact_serialized_contract() {
2852        let mut store = CountingStore::new();
2853        let target = node("target", false);
2854        let middle = node("middle", false);
2855        let entry = node("entry", true);
2856        for fixture_node in [&target, &middle, &entry] {
2857            store.add_node(fixture_node.clone());
2858        }
2859        store.add_caller(&target, &middle);
2860        store.add_caller(&middle, &entry);
2861
2862        let (result, _expansions) = trace_to_result_with_budget(
2863            &store,
2864            Path::new(&target.file),
2865            &target.symbol,
2866            10,
2867            true,
2868            100,
2869        )
2870        .expect("trace result");
2871
2872        assert_eq!(
2873            serde_json::to_string(&result).expect("serialize trace result"),
2874            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}"#
2875        );
2876    }
2877}