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;
25const 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 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 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, ¤t.nodes)?;
1101
1102 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 ¤t_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 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
1754pub 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
1765pub fn unavailable_response(req_id: &str, operation: &str, worktree: bool) -> Response {
1766 let message = if worktree {
1767 format!(
1768 "{operation}: persisted callgraph store is unavailable in this read-only worktree; run a callgraph operation in the main checkout to build it first"
1769 )
1770 } else {
1771 format!("{operation}: project not configured — send 'configure' first")
1772 };
1773 let code = if worktree {
1774 "callgraph_unavailable"
1775 } else {
1776 "not_configured"
1777 };
1778 Response::error(req_id, code, message)
1779}
1780
1781fn resolve_symbol_query(
1782 store: &impl CallGraphRead,
1783 file: &Path,
1784 symbol: &str,
1785) -> StoreAdapterResult<ResolvedStoreSymbol> {
1786 let nodes = store.nodes_for(file, symbol)?;
1787 collapse_symbol_nodes(store, file, symbol, nodes)
1788}
1789
1790fn resolve_exact_symbol(
1791 store: &impl CallGraphRead,
1792 file: &str,
1793 symbol: &str,
1794 fallback: Option<StoreNode>,
1795) -> StoreAdapterResult<Option<ResolvedStoreSymbol>> {
1796 let nodes = store
1797 .nodes_for(Path::new(file), symbol)?
1798 .into_iter()
1799 .filter(|node| node.symbol == symbol)
1800 .collect::<Vec<_>>();
1801 if nodes.is_empty() {
1802 return Ok(fallback.map(|node| ResolvedStoreSymbol {
1803 representative: node.clone(),
1804 nodes: vec![node],
1805 }));
1806 }
1807 Ok(Some(collapse_exact_nodes(nodes)))
1808}
1809
1810fn collapse_symbol_nodes(
1811 store: &impl CallGraphRead,
1812 file: &Path,
1813 query: &str,
1814 nodes: Vec<StoreNode>,
1815) -> StoreAdapterResult<ResolvedStoreSymbol> {
1816 let mut by_symbol: BTreeMap<String, Vec<StoreNode>> = BTreeMap::new();
1817 for node in nodes {
1818 by_symbol.entry(node.symbol.clone()).or_default().push(node);
1819 }
1820
1821 match by_symbol.len() {
1822 0 => Err(CallGraphStoreError::Aft(AftError::SymbolNotFound {
1823 name: query.to_string(),
1824 file: display_file_for_error(store, file),
1825 })),
1826 1 => Ok(collapse_exact_nodes(
1827 by_symbol.into_values().next().unwrap_or_default(),
1828 )),
1829 _ => Err(CallGraphStoreError::Aft(AftError::AmbiguousSymbol {
1830 name: query.to_string(),
1831 candidates: by_symbol.into_keys().collect(),
1832 })),
1833 }
1834}
1835
1836fn collapse_exact_nodes(mut nodes: Vec<StoreNode>) -> ResolvedStoreSymbol {
1837 nodes.sort_by(|left, right| {
1838 left.symbol
1839 .cmp(&right.symbol)
1840 .then(left.line.cmp(&right.line))
1841 .then(left.end_line.cmp(&right.end_line))
1842 });
1843 let representative = nodes[0].clone();
1844 ResolvedStoreSymbol {
1845 representative,
1846 nodes,
1847 }
1848}
1849
1850fn prefetch_callers(
1851 store: &impl CallGraphRead,
1852 file: &str,
1853 symbol: &str,
1854 max_depth: usize,
1855) -> StoreAdapterResult<HashMap<(String, String), Vec<StoreCallSite>>> {
1856 let mut callers_by_symbol = HashMap::new();
1857 let mut frontier = BTreeSet::from([(file.to_string(), symbol.to_string())]);
1858
1859 for depth in 0..max_depth {
1860 let targets = frontier
1861 .into_iter()
1862 .filter(|target| !callers_by_symbol.contains_key(target))
1863 .collect::<Vec<_>>();
1864 if targets.is_empty() {
1865 break;
1866 }
1867 let fetched = store.direct_callers_for_symbols(&targets)?;
1868 let mut next_frontier = BTreeSet::new();
1869 if depth + 1 < max_depth {
1870 for site in fetched.values().flatten() {
1871 next_frontier.insert((site.caller.file.clone(), site.caller.symbol.clone()));
1872 }
1873 }
1874 callers_by_symbol.extend(fetched);
1875 frontier = next_frontier;
1876 }
1877
1878 Ok(callers_by_symbol)
1879}
1880
1881#[allow(clippy::too_many_arguments)]
1882fn collect_callers_recursive(
1883 store: &impl CallGraphRead,
1884 callers_by_symbol: &HashMap<(String, String), Vec<StoreCallSite>>,
1885 file: &str,
1886 symbol: &str,
1887 max_depth: usize,
1888 current_depth: usize,
1889 visited: &mut HashSet<(String, String)>,
1890 result: &mut Vec<StoreCallSite>,
1891 depth_limited: &mut bool,
1892 truncated: &mut usize,
1893) -> StoreAdapterResult<()> {
1894 if current_depth >= max_depth {
1895 let target = (file.to_string(), symbol.to_string());
1896 let counts = store.direct_caller_counts_of(std::slice::from_ref(&target))?;
1897 let omitted = counts.get(&target).copied().unwrap_or_default();
1898 if omitted > 0 {
1899 *depth_limited = true;
1900 *truncated += omitted;
1901 }
1902 return Ok(());
1903 }
1904
1905 if !visited.insert((file.to_string(), symbol.to_string())) {
1906 return Ok(());
1907 }
1908
1909 let Some(sites) = callers_by_symbol.get(&(file.to_string(), symbol.to_string())) else {
1910 return Ok(());
1911 };
1912 if sites.is_empty() {
1913 return Ok(());
1914 }
1915 if current_depth + 1 < max_depth {
1916 for site in sites {
1917 result.push(site.clone());
1918 collect_callers_recursive(
1919 store,
1920 callers_by_symbol,
1921 &site.caller.file,
1922 &site.caller.symbol,
1923 max_depth,
1924 current_depth + 1,
1925 visited,
1926 result,
1927 depth_limited,
1928 truncated,
1929 )?;
1930 }
1931 } else {
1932 let boundary_targets = sites
1933 .iter()
1934 .map(|site| (site.caller.file.clone(), site.caller.symbol.clone()))
1935 .collect::<BTreeSet<_>>()
1936 .into_iter()
1937 .collect::<Vec<_>>();
1938 let boundary_counts = store.direct_caller_counts_of(&boundary_targets)?;
1939 for site in sites {
1940 result.push(site.clone());
1941 let key = (site.caller.file.clone(), site.caller.symbol.clone());
1942 let omitted = boundary_counts.get(&key).copied().unwrap_or_default();
1943 if omitted > 0 {
1944 *depth_limited = true;
1945 *truncated += omitted;
1946 }
1947 }
1948 }
1949 Ok(())
1950}
1951
1952#[allow(clippy::too_many_arguments)]
1953fn call_tree_inner(
1954 store: &impl CallGraphRead,
1955 current: &ResolvedStoreSymbol,
1956 max_depth: usize,
1957 current_depth: usize,
1958 visited: &mut HashSet<(String, String)>,
1959 adjacency_cache: &mut HashMap<(String, String), Vec<ForwardCall>>,
1960 memoize_adjacency: bool,
1961) -> StoreAdapterResult<StoreCallTreeNode> {
1962 let node = ¤t.representative;
1963 let visit_key = (node.file.clone(), node.symbol.clone());
1964 if visited.contains(&visit_key) {
1965 return Ok(StoreCallTreeNode {
1966 name: node.symbol.clone(),
1967 file: node.file.clone(),
1968 line: node.line,
1969 signature: node.signature.clone(),
1970 resolved: true,
1971 approximate: None,
1972 resolved_by: None,
1973 children: Vec::new(),
1974 depth_limited: false,
1975 truncated: 0,
1976 });
1977 }
1978 visited.insert(visit_key.clone());
1979
1980 let calls = if memoize_adjacency {
1983 if let Some(calls) = adjacency_cache.get(&visit_key) {
1984 calls.clone()
1985 } else {
1986 let calls = forward_calls_for_nodes(store, ¤t.nodes)?;
1987 adjacency_cache.insert(visit_key.clone(), calls.clone());
1988 calls
1989 }
1990 } else {
1991 forward_calls_for_nodes(store, ¤t.nodes)?
1992 };
1993 let mut children = Vec::new();
1994 let mut depth_limited = false;
1995 let mut truncated = 0usize;
1996
1997 if current_depth < max_depth {
1998 for call in calls {
1999 match call {
2000 ForwardCall::Resolved(site) => {
2001 let resolved = resolve_exact_symbol(
2002 store,
2003 &site.target_file,
2004 &site.target_symbol,
2005 site.target.clone(),
2006 )?;
2007 if let Some(child_symbol) = resolved {
2008 let mut child = call_tree_inner(
2009 store,
2010 &child_symbol,
2011 max_depth,
2012 current_depth + 1,
2013 visited,
2014 adjacency_cache,
2015 memoize_adjacency,
2016 )?;
2017 child.approximate = edge_approximate(&site);
2018 child.resolved_by = edge_resolved_by(&site);
2019 depth_limited |= child.depth_limited;
2020 truncated += child.truncated;
2021 children.push(child);
2022 } else {
2023 children.push(StoreCallTreeNode {
2024 name: site.target_symbol.clone(),
2025 file: site.target_file.clone(),
2026 line: site.line,
2027 signature: None,
2028 resolved: false,
2029 approximate: edge_approximate(&site),
2030 resolved_by: edge_resolved_by(&site),
2031 children: Vec::new(),
2032 depth_limited: false,
2033 truncated: 0,
2034 });
2035 }
2036 }
2037 ForwardCall::Unresolved(call) => children.push(StoreCallTreeNode {
2038 name: call.symbol,
2039 file: call.caller.file,
2040 line: call.line,
2041 signature: None,
2042 resolved: false,
2043 approximate: None,
2044 resolved_by: None,
2045 children: Vec::new(),
2046 depth_limited: false,
2047 truncated: 0,
2048 }),
2049 }
2050 }
2051 } else if !calls.is_empty() {
2052 depth_limited = true;
2053 truncated = calls.len();
2054 }
2055
2056 visited.remove(&visit_key);
2057 Ok(StoreCallTreeNode {
2058 name: node.symbol.clone(),
2059 file: node.file.clone(),
2060 line: node.line,
2061 signature: node.signature.clone(),
2062 resolved: true,
2063 approximate: None,
2064 resolved_by: None,
2065 children,
2066 depth_limited,
2067 truncated,
2068 })
2069}
2070
2071fn forward_calls_for_nodes(
2072 store: &impl CallGraphRead,
2073 nodes: &[StoreNode],
2074) -> StoreAdapterResult<Vec<ForwardCall>> {
2075 let mut calls = Vec::new();
2076 for node in nodes {
2077 calls.extend(
2078 store
2079 .outgoing_calls_of(node)?
2080 .into_iter()
2081 .map(ForwardCall::Resolved),
2082 );
2083 calls.extend(
2084 store
2085 .unresolved_calls_of(node)?
2086 .into_iter()
2087 .map(ForwardCall::Unresolved),
2088 );
2089 }
2090 calls.sort_by(|left, right| {
2091 left.byte_start()
2092 .cmp(&right.byte_start())
2093 .then(left.line().cmp(&right.line()))
2094 });
2095 let mut seen = BTreeSet::new();
2096 calls.retain(|call| seen.insert(call.call_site_key()));
2097 Ok(calls)
2098}
2099
2100fn forward_resolved_callees(calls: Vec<StoreCallSite>) -> Vec<(StoreNode, EdgeMarker)> {
2101 let mut calls = dedup_call_sites(calls);
2102 calls.sort_by(|left, right| {
2103 left.byte_start
2104 .cmp(&right.byte_start)
2105 .then(left.line.cmp(&right.line))
2106 });
2107
2108 calls
2109 .into_iter()
2110 .filter_map(|site| {
2111 let edge = edge_marker(&site);
2112 site.target.map(|target| (target, edge))
2113 })
2114 .collect()
2115}
2116
2117fn dedup_call_sites(sites: Vec<StoreCallSite>) -> Vec<StoreCallSite> {
2118 let mut seen = HashSet::new();
2119 let mut deduped = Vec::new();
2120 for site in sites {
2121 if seen.insert(call_site_key(&site)) {
2122 deduped.push(site);
2123 }
2124 }
2125 deduped
2126}
2127
2128#[cfg(test)]
2129fn dedup_call_site_count(sites: Vec<StoreCallSite>) -> usize {
2130 sites
2131 .into_iter()
2132 .map(|site| call_site_key(&site))
2133 .collect::<HashSet<_>>()
2134 .len()
2135}
2136
2137fn call_site_key(site: &StoreCallSite) -> (String, u32, String, String) {
2138 (
2139 site.caller.file.clone(),
2140 site.line,
2141 site.target_file.clone(),
2142 site.target_symbol.clone(),
2143 )
2144}
2145
2146fn trace_to_symbol_hop(node: &StoreNode) -> StoreTraceToSymbolHop {
2147 trace_to_symbol_hop_with_edge(node, EdgeMarker::default())
2148}
2149
2150fn trace_to_symbol_hop_with_edge(node: &StoreNode, edge: EdgeMarker) -> StoreTraceToSymbolHop {
2151 StoreTraceToSymbolHop {
2152 symbol: node.symbol.clone(),
2153 file: node.file.clone(),
2154 line: node.line,
2155 approximate: edge.approximate,
2156 resolved_by: edge.resolved_by,
2157 }
2158}
2159
2160fn trace_to_symbol_matches_target(
2161 file: &str,
2162 symbol: &str,
2163 to_symbol: &str,
2164 to_file: Option<&str>,
2165) -> bool {
2166 if !(symbol == to_symbol || unqualified_name(symbol) == to_symbol) {
2167 return false;
2168 }
2169 match to_file {
2170 Some(target_file) => file == target_file,
2171 None => true,
2172 }
2173}
2174
2175fn unqualified_name(symbol: &str) -> &str {
2176 symbol.rsplit("::").next().unwrap_or(symbol)
2177}
2178
2179fn read_source_line(path: &Path, line: u32) -> Option<String> {
2180 let source = std::fs::read_to_string(path).ok()?;
2181 source
2182 .lines()
2183 .nth(line.saturating_sub(1) as usize)
2184 .map(|line| line.trim().to_string())
2185}
2186
2187fn display_file_for_error(store: &impl CallGraphRead, file: &Path) -> String {
2188 absolute_file(store, file).display().to_string()
2189}
2190
2191fn relative_file(store: &impl CallGraphRead, file: &Path) -> String {
2192 let absolute = absolute_file(store, file);
2193 absolute
2194 .strip_prefix(store.project_root())
2195 .unwrap_or(&absolute)
2196 .to_string_lossy()
2197 .replace('\\', "/")
2198}
2199
2200fn absolute_file(store: &impl CallGraphRead, file: &Path) -> PathBuf {
2201 let full_path = if file.is_relative() {
2202 store.project_root().join(file)
2203 } else {
2204 file.to_path_buf()
2205 };
2206 std::fs::canonicalize(&full_path).unwrap_or(full_path)
2207}
2208
2209#[cfg(test)]
2210mod trace_to_tests {
2211 use super::*;
2212 use crate::callgraph_store::{
2213 Result as CallGraphResult, StoreCallersResult as RawCallersResult,
2214 StoreImpactResult as RawImpactResult, StoredEdge,
2215 };
2216 use std::cell::RefCell;
2217
2218 struct CountingStore {
2219 root: PathBuf,
2220 sqlite_path: PathBuf,
2221 nodes: HashMap<(String, String), StoreNode>,
2222 callers: HashMap<(String, String), Vec<StoreCallSite>>,
2223 outgoing: HashMap<(String, String), Vec<StoreCallSite>>,
2224 caller_queries: RefCell<HashMap<(String, String), usize>>,
2225 caller_frontier_query_count: RefCell<usize>,
2226 caller_frontier_target_count: RefCell<usize>,
2227 forward_query_count: RefCell<usize>,
2228 frontier_query_count: RefCell<usize>,
2229 caller_count_queries: RefCell<usize>,
2230 caller_count_targets: RefCell<usize>,
2231 }
2232
2233 impl CountingStore {
2234 fn new() -> Self {
2235 Self {
2236 root: PathBuf::from("/repo"),
2237 sqlite_path: PathBuf::from("/repo/callgraph.sqlite"),
2238 nodes: HashMap::new(),
2239 callers: HashMap::new(),
2240 outgoing: HashMap::new(),
2241 caller_queries: RefCell::new(HashMap::new()),
2242 caller_frontier_query_count: RefCell::new(0),
2243 caller_frontier_target_count: RefCell::new(0),
2244 forward_query_count: RefCell::new(0),
2245 frontier_query_count: RefCell::new(0),
2246 caller_count_queries: RefCell::new(0),
2247 caller_count_targets: RefCell::new(0),
2248 }
2249 }
2250
2251 fn add_node(&mut self, node: StoreNode) {
2252 self.nodes
2253 .insert((node.file.clone(), node.symbol.clone()), node);
2254 }
2255
2256 fn add_caller(&mut self, target: &StoreNode, caller: &StoreNode) {
2257 self.add_caller_at(target, caller, caller.line);
2258 }
2259
2260 fn add_caller_at(&mut self, target: &StoreNode, caller: &StoreNode, line: u32) {
2261 self.callers
2262 .entry((target.file.clone(), target.symbol.clone()))
2263 .or_default()
2264 .push(StoreCallSite {
2265 caller: caller.clone(),
2266 target_file: target.file.clone(),
2267 target_symbol: target.symbol.clone(),
2268 target: Some(target.clone()),
2269 line,
2270 byte_start: 0,
2271 byte_end: 1,
2272 resolved: true,
2273 provenance: TRACE_DATA_RESOLVER_PROVENANCE.to_string(),
2274 });
2275 }
2276
2277 fn add_outgoing(&mut self, caller: &StoreNode, target: &StoreNode) {
2278 self.outgoing
2279 .entry((caller.file.clone(), caller.symbol.clone()))
2280 .or_default()
2281 .push(StoreCallSite {
2282 caller: caller.clone(),
2283 target_file: target.file.clone(),
2284 target_symbol: target.symbol.clone(),
2285 target: Some(target.clone()),
2286 line: target.line,
2287 byte_start: 0,
2288 byte_end: 1,
2289 resolved: true,
2290 provenance: TRACE_DATA_RESOLVER_PROVENANCE.to_string(),
2291 });
2292 }
2293
2294 fn total_forward_queries(&self) -> usize {
2295 *self.forward_query_count.borrow()
2296 }
2297
2298 fn reset_forward_queries(&self) {
2299 *self.forward_query_count.borrow_mut() = 0;
2300 }
2301
2302 fn total_frontier_queries(&self) -> usize {
2303 *self.frontier_query_count.borrow()
2304 }
2305
2306 fn total_caller_queries(&self) -> usize {
2307 self.caller_queries.borrow().values().sum()
2308 }
2309
2310 fn total_caller_frontier_queries(&self) -> usize {
2311 *self.caller_frontier_query_count.borrow()
2312 }
2313
2314 fn caller_frontier_target_count(&self) -> usize {
2315 *self.caller_frontier_target_count.borrow()
2316 }
2317
2318 fn total_caller_count_queries(&self) -> usize {
2319 *self.caller_count_queries.borrow()
2320 }
2321
2322 fn caller_count_target_count(&self) -> usize {
2323 *self.caller_count_targets.borrow()
2324 }
2325
2326 fn reset_query_counts(&self) {
2327 self.caller_queries.borrow_mut().clear();
2328 *self.caller_frontier_query_count.borrow_mut() = 0;
2329 *self.caller_frontier_target_count.borrow_mut() = 0;
2330 *self.caller_count_queries.borrow_mut() = 0;
2331 *self.caller_count_targets.borrow_mut() = 0;
2332 }
2333 }
2334
2335 impl CallGraphRead for CountingStore {
2336 fn project_root(&self) -> &Path {
2337 &self.root
2338 }
2339
2340 fn project_key(&self) -> &str {
2341 "test-project"
2342 }
2343
2344 fn sqlite_path(&self) -> &Path {
2345 &self.sqlite_path
2346 }
2347
2348 fn is_current(&self) -> bool {
2349 true
2350 }
2351
2352 fn edge_snapshot(&self) -> CallGraphResult<BTreeSet<StoredEdge>> {
2353 unreachable!("not used by trace_to_result")
2354 }
2355
2356 fn indexed_file_count(&self) -> CallGraphResult<usize> {
2357 Ok(self.nodes.len())
2358 }
2359
2360 fn node_for(&self, file_rel: &Path, symbol: &str) -> CallGraphResult<StoreNode> {
2361 Ok(self
2362 .nodes_for(file_rel, symbol)?
2363 .into_iter()
2364 .next()
2365 .expect("fixture node"))
2366 }
2367
2368 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> CallGraphResult<Vec<StoreNode>> {
2369 let key = (
2370 file_rel.to_string_lossy().replace('\\', "/"),
2371 symbol.to_string(),
2372 );
2373 Ok(self.nodes.get(&key).cloned().into_iter().collect())
2374 }
2375
2376 fn nodes_matching(&self, symbol: &str) -> CallGraphResult<Vec<StoreNode>> {
2377 Ok(self
2378 .nodes
2379 .values()
2380 .filter(|node| node.symbol == symbol)
2381 .cloned()
2382 .collect())
2383 }
2384
2385 fn direct_callers_of(
2386 &self,
2387 file_rel: &Path,
2388 symbol: &str,
2389 ) -> CallGraphResult<Vec<StoreCallSite>> {
2390 let key = (
2391 file_rel.to_string_lossy().replace('\\', "/"),
2392 symbol.to_string(),
2393 );
2394 *self
2395 .caller_queries
2396 .borrow_mut()
2397 .entry(key.clone())
2398 .or_default() += 1;
2399 Ok(self.callers.get(&key).cloned().unwrap_or_default())
2400 }
2401
2402 fn direct_callers_for_symbols(
2403 &self,
2404 targets: &[(String, String)],
2405 ) -> CallGraphResult<HashMap<(String, String), Vec<StoreCallSite>>> {
2406 *self.caller_frontier_query_count.borrow_mut() += 1;
2407 *self.caller_frontier_target_count.borrow_mut() += targets.len();
2408 Ok(targets
2409 .iter()
2410 .cloned()
2411 .map(|target| {
2412 let callers = self.callers.get(&target).cloned().unwrap_or_default();
2413 (target, callers)
2414 })
2415 .collect())
2416 }
2417
2418 fn direct_caller_counts_of(
2419 &self,
2420 targets: &[(String, String)],
2421 ) -> CallGraphResult<HashMap<(String, String), usize>> {
2422 *self.caller_count_queries.borrow_mut() += 1;
2423 *self.caller_count_targets.borrow_mut() = targets.len();
2424 Ok(targets
2425 .iter()
2426 .cloned()
2427 .map(|target| {
2428 let count = self
2429 .callers
2430 .get(&target)
2431 .cloned()
2432 .map(dedup_call_site_count)
2433 .unwrap_or_default();
2434 (target, count)
2435 })
2436 .collect())
2437 }
2438
2439 fn callers_of(
2440 &self,
2441 _file_rel: &Path,
2442 _symbol: &str,
2443 _depth: usize,
2444 ) -> CallGraphResult<RawCallersResult> {
2445 unreachable!("not used by trace_to_result")
2446 }
2447
2448 fn impact_of(
2449 &self,
2450 _file_rel: &Path,
2451 _symbol: &str,
2452 _depth: usize,
2453 ) -> CallGraphResult<RawImpactResult> {
2454 unreachable!("not used by trace_to_result")
2455 }
2456
2457 fn outgoing_calls_of(&self, node: &StoreNode) -> CallGraphResult<Vec<StoreCallSite>> {
2458 *self.forward_query_count.borrow_mut() += 1;
2459 Ok(self
2460 .outgoing
2461 .get(&(node.file.clone(), node.symbol.clone()))
2462 .cloned()
2463 .unwrap_or_default())
2464 }
2465
2466 fn outgoing_calls_for_symbols(
2467 &self,
2468 sources: &[(String, String)],
2469 ) -> CallGraphResult<HashMap<(String, String), Vec<StoreCallSite>>> {
2470 *self.frontier_query_count.borrow_mut() += 1;
2471 Ok(sources
2472 .iter()
2473 .cloned()
2474 .map(|source| {
2475 let calls = self.outgoing.get(&source).cloned().unwrap_or_default();
2476 (source, calls)
2477 })
2478 .collect())
2479 }
2480
2481 fn resolved_self_calls_of(&self, _node: &StoreNode) -> CallGraphResult<Vec<StoreCallSite>> {
2482 unreachable!("not used by these adapter tests")
2483 }
2484
2485 fn unresolved_calls_of(
2486 &self,
2487 _node: &StoreNode,
2488 ) -> CallGraphResult<Vec<StoreUnresolvedCall>> {
2489 *self.forward_query_count.borrow_mut() += 1;
2490 Ok(Vec::new())
2491 }
2492
2493 fn call_tree(
2494 &self,
2495 _file_rel: &Path,
2496 _symbol: &str,
2497 _depth: usize,
2498 ) -> CallGraphResult<callgraph::CallTreeNode> {
2499 unreachable!("not used by trace_to_result")
2500 }
2501
2502 fn trace_to(
2503 &self,
2504 _file_rel: &Path,
2505 _symbol: &str,
2506 _max_depth: usize,
2507 ) -> CallGraphResult<callgraph::TraceToResult> {
2508 unreachable!("not used by trace_to_result")
2509 }
2510
2511 fn trace_to_symbol_candidates(
2512 &self,
2513 _to_symbol: &str,
2514 ) -> CallGraphResult<Vec<TraceToSymbolCandidate>> {
2515 unreachable!("not used by trace_to_result")
2516 }
2517
2518 fn trace_to_symbol(
2519 &self,
2520 _file_rel: &Path,
2521 _symbol: &str,
2522 _to_symbol: &str,
2523 _to_file: Option<&Path>,
2524 _max_depth: usize,
2525 ) -> CallGraphResult<callgraph::TraceToSymbolResult> {
2526 unreachable!("not used by trace_to_result")
2527 }
2528 }
2529
2530 fn node(symbol: &str, is_entry_point: bool) -> StoreNode {
2531 StoreNode::for_test(&format!("{symbol}.ts"), symbol, is_entry_point)
2532 }
2533
2534 fn layered_store(width: usize, layers: usize) -> (CountingStore, StoreNode) {
2535 let mut store = CountingStore::new();
2536 let target = node("target", false);
2537 store.add_node(target.clone());
2538 let mut previous = vec![target.clone()];
2539 for layer in 1..=layers {
2540 let current = (0..width)
2541 .map(|index| node(&format!("layer_{layer}_{index}"), layer == layers))
2542 .collect::<Vec<_>>();
2543 for caller in ¤t {
2544 store.add_node(caller.clone());
2545 }
2546 for target_node in &previous {
2547 for caller in ¤t {
2548 store.add_caller(target_node, caller);
2549 }
2550 }
2551 previous = current;
2552 }
2553 (store, target)
2554 }
2555
2556 fn converging_call_tree_store(width: usize) -> (CountingStore, StoreNode) {
2557 let mut store = CountingStore::new();
2558 let root = node("root", false);
2559 let helper = node("helper", false);
2560 let leaf = node("leaf", false);
2561 for fixture_node in [&root, &helper, &leaf] {
2562 store.add_node(fixture_node.clone());
2563 }
2564 store.add_outgoing(&helper, &leaf);
2565
2566 for index in 0..width {
2567 let handler = node(&format!("handler_{index}"), false);
2568 store.add_node(handler.clone());
2569 store.add_outgoing(&root, &handler);
2570 store.add_outgoing(&handler, &helper);
2571 }
2572 (store, root)
2573 }
2574
2575 fn call_tree_node_count(tree: &StoreCallTreeNode) -> usize {
2576 1 + tree
2577 .children
2578 .iter()
2579 .map(call_tree_node_count)
2580 .sum::<usize>()
2581 }
2582
2583 #[test]
2584 fn call_tree_memoizes_only_adjacency_and_preserves_rendered_tree() {
2585 let (store, root) = converging_call_tree_store(200);
2586
2587 let memoized = call_tree_result(&store, Path::new(&root.file), &root.symbol, 3, true)
2588 .expect("memoized call tree");
2589 let memoized_queries = store.total_forward_queries();
2590
2591 store.reset_forward_queries();
2592 let resolved_root = ResolvedStoreSymbol {
2593 representative: root.clone(),
2594 nodes: vec![root],
2595 };
2596 let mut visited = HashSet::new();
2597 let mut unused_cache = HashMap::new();
2598 let uncached = call_tree_inner(
2599 &store,
2600 &resolved_root,
2601 3,
2602 0,
2603 &mut visited,
2604 &mut unused_cache,
2605 false,
2606 )
2607 .expect("uncached call tree");
2608 let uncached_queries = store.total_forward_queries();
2609
2610 assert_eq!(call_tree_node_count(&memoized), 601);
2611 assert_eq!(
2612 serde_json::to_vec(&memoized).expect("serialize memoized tree"),
2613 serde_json::to_vec(&uncached).expect("serialize uncached tree"),
2614 "adjacency memoization must not change rendered call-tree bytes"
2615 );
2616 assert_eq!(uncached_queries, 1_202);
2617 assert_eq!(memoized_queries, 406);
2618 }
2619
2620 #[test]
2621 fn reverse_frontiers_batch_caller_fetches_by_depth() {
2622 let (store, target) = layered_store(20, 6);
2623
2624 let result = callers_result(&store, Path::new(&target.file), &target.symbol, 5, true)
2625 .expect("callers result");
2626
2627 assert_eq!(store.total_caller_queries(), 0);
2628 assert_eq!(store.total_caller_frontier_queries(), 5);
2629 assert_eq!(store.caller_frontier_target_count(), 81);
2630 assert_eq!(result.total_callers, 1_620);
2631 assert_eq!(result.callers.len(), 15);
2632 assert!(result.depth_limited);
2633 }
2634
2635 #[test]
2636 fn batched_boundary_counts_preserve_serialized_callers_and_impact_contract() {
2637 let mut store = CountingStore::new();
2638 let target = node("target", false);
2639 let boundary = node("hubCaller", false);
2640 let upstream_a = node("upstreamA", true);
2641 let upstream_b = node("upstreamB", true);
2642 for fixture_node in [&target, &boundary, &upstream_a, &upstream_b] {
2643 store.add_node(fixture_node.clone());
2644 }
2645 for line in 1..=21 {
2646 store.add_caller_at(&target, &boundary, line);
2647 }
2648 store.add_caller(&boundary, &upstream_a);
2649 store.add_caller(&boundary, &upstream_b);
2650
2651 let callers = callers_result(&store, Path::new(&target.file), &target.symbol, 1, true)
2652 .expect("callers result");
2653 assert_eq!(store.total_caller_queries(), 0);
2654 assert_eq!(store.total_caller_frontier_queries(), 1);
2655 assert_eq!(store.caller_frontier_target_count(), 1);
2656 assert_eq!(store.total_caller_count_queries(), 1);
2657 assert_eq!(store.caller_count_target_count(), 1);
2658 assert_eq!(
2659 serde_json::to_string(&callers).expect("serialize callers result"),
2660 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}"#
2661 );
2662
2663 store.reset_query_counts();
2664 let impact = impact_result(&store, Path::new(&target.file), &target.symbol, 1, true)
2665 .expect("impact result");
2666 assert_eq!(store.total_caller_queries(), 0);
2667 assert_eq!(store.total_caller_frontier_queries(), 1);
2668 assert_eq!(store.caller_frontier_target_count(), 1);
2669 assert_eq!(store.total_caller_count_queries(), 1);
2670 assert_eq!(store.caller_count_target_count(), 1);
2671 assert_eq!(
2672 serde_json::to_string(&impact).expect("serialize impact result"),
2673 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}"#
2674 );
2675 }
2676
2677 #[test]
2678 fn trace_to_symbol_batches_frontiers_without_changing_shortest_path() {
2679 let mut store = CountingStore::new();
2680 let origin = node("origin", false);
2681 let slow = node("slow", false);
2682 let slow_middle = node("slowMiddle", false);
2683 let fast = node("fast", false);
2684 let target = node("target", false);
2685 for fixture_node in [&origin, &slow, &slow_middle, &fast, &target] {
2686 store.add_node(fixture_node.clone());
2687 }
2688 store.add_outgoing(&origin, &slow);
2689 store.add_outgoing(&origin, &fast);
2690 store.add_outgoing(&slow, &slow_middle);
2691 store.add_outgoing(&slow_middle, &target);
2692 store.add_outgoing(&fast, &target);
2693
2694 let result = trace_to_symbol_result(
2695 &store,
2696 Path::new(&origin.file),
2697 &origin.symbol,
2698 &target.symbol,
2699 None,
2700 10,
2701 true,
2702 )
2703 .expect("trace-to-symbol result");
2704 let symbols = result
2705 .path
2706 .expect("shortest path")
2707 .into_iter()
2708 .map(|hop| hop.symbol)
2709 .collect::<Vec<_>>();
2710
2711 assert_eq!(symbols, vec!["origin", "fast", "target"]);
2712 assert_eq!(store.total_frontier_queries(), 2);
2713 assert_eq!(store.total_forward_queries(), 0);
2714 }
2715
2716 #[test]
2717 fn trace_to_caches_callers_for_convergent_path_prefixes() {
2718 let (store, target) = layered_store(2, 3);
2719
2720 let (result, expansions) = trace_to_result_with_budget(
2721 &store,
2722 Path::new(&target.file),
2723 &target.symbol,
2724 10,
2725 true,
2726 100,
2727 )
2728 .expect("trace result");
2729
2730 assert_eq!(result.total_paths, 8);
2731 assert!(!result.total_paths_is_lower_bound);
2732 assert_eq!(expansions, 15);
2733 assert_eq!(store.total_caller_queries(), 7);
2734 assert!(store
2735 .caller_queries
2736 .borrow()
2737 .values()
2738 .all(|queries| *queries == 1));
2739 }
2740
2741 #[test]
2742 fn trace_to_budget_returns_valid_paths_and_marks_counts_as_lower_bounds() {
2743 let (store, target) = layered_store(2, 3);
2744
2745 let (result, expansions) = trace_to_result_with_budget(
2746 &store,
2747 Path::new(&target.file),
2748 &target.symbol,
2749 10,
2750 true,
2751 10,
2752 )
2753 .expect("trace result");
2754
2755 assert!(result.total_paths_is_lower_bound);
2756 assert!(result.total_paths > 0);
2757 assert!(expansions <= 10);
2758 assert!(store.total_caller_queries() <= 10);
2759 let summary = result.hub_summary.expect("lower-bound summary");
2760 assert!(summary.counts_are_lower_bounds);
2761 assert!(summary.message.contains("at least"));
2762 for path in result.paths {
2763 assert!(path.hops.first().is_some_and(|hop| hop.is_entry_point));
2764 assert_eq!(
2765 path.hops.last().map(|hop| hop.symbol.as_str()),
2766 Some("target")
2767 );
2768 }
2769 }
2770
2771 #[test]
2772 fn trace_to_below_budget_preserves_exact_serialized_contract() {
2773 let mut store = CountingStore::new();
2774 let target = node("target", false);
2775 let middle = node("middle", false);
2776 let entry = node("entry", true);
2777 for fixture_node in [&target, &middle, &entry] {
2778 store.add_node(fixture_node.clone());
2779 }
2780 store.add_caller(&target, &middle);
2781 store.add_caller(&middle, &entry);
2782
2783 let (result, _expansions) = trace_to_result_with_budget(
2784 &store,
2785 Path::new(&target.file),
2786 &target.symbol,
2787 10,
2788 true,
2789 100,
2790 )
2791 .expect("trace result");
2792
2793 assert_eq!(
2794 serde_json::to_string(&result).expect("serialize trace result"),
2795 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}"#
2796 );
2797 }
2798}