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