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