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