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