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