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