Skip to main content

aft/commands/
callgraph_store_adapter.rs

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