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
451 collect_callers_recursive(
452 store,
453 &target.representative.file,
454 &target.representative.symbol,
455 effective_depth,
456 0,
457 &mut visited,
458 &mut sites,
459 &mut depth_limited,
460 &mut truncated,
461 )?;
462
463 let mut sites = dedup_call_sites(sites);
464 sites.sort_by(|left, right| {
465 left.caller
466 .file
467 .cmp(&right.caller.file)
468 .then(left.line.cmp(&right.line))
469 .then(left.caller.symbol.cmp(&right.caller.symbol))
470 });
471 let total_callers = sites.len();
472 let hidden_tests = sites
473 .iter()
474 .filter(|site| callsite_is_from_test(site))
475 .count();
476 let summarize = total_callers > HUB_SUMMARY_THRESHOLD;
477 let visible_sites = sites
478 .into_iter()
479 .filter(|site| include_tests || !callsite_is_from_test(site))
480 .collect::<Vec<_>>();
481 let visible_sites = if summarize {
482 dedup_sites_for_summary(visible_sites)
483 .into_iter()
484 .take(HUB_SUMMARY_LIMIT)
485 .collect::<Vec<_>>()
486 } else {
487 visible_sites
488 };
489 let hub_summary = if summarize {
490 Some(if include_tests {
491 included_summary("callers", total_callers, hidden_tests, visible_sites.len())
492 } else {
493 test_hidden_summary("callers", total_callers, hidden_tests, visible_sites.len())
494 })
495 } else {
496 None
497 };
498 let mut groups: BTreeMap<String, Vec<StoreCallerEntry>> = BTreeMap::new();
499 for site in visible_sites {
500 groups
501 .entry(site.caller.file.clone())
502 .or_default()
503 .push(StoreCallerEntry {
504 symbol: site.caller.symbol.clone(),
505 line: site.line,
506 approximate: edge_approximate(&site),
507 resolved_by: edge_resolved_by(&site),
508 });
509 }
510
511 Ok(StoreCallersResult {
512 symbol: target.representative.symbol,
513 file: target.representative.file,
514 callers: groups
515 .into_iter()
516 .map(|(file, callers)| StoreCallerGroup { file, callers })
517 .collect(),
518 total_callers,
519 hub_summary,
520 scanned_files: store.indexed_file_count()?,
521 depth_limited,
522 truncated,
523 })
524}
525
526pub fn call_tree_result(
527 store: &impl CallGraphRead,
528 file: &Path,
529 symbol: &str,
530 depth: usize,
531 include_tests: bool,
532) -> StoreAdapterResult<StoreCallTreeNode> {
533 let target = resolve_symbol_query(store, file, symbol)?;
534 let mut visited = HashSet::new();
535 let mut adjacency_cache = HashMap::new();
536 let mut tree = call_tree_inner(
537 store,
538 &target,
539 depth,
540 0,
541 &mut visited,
542 &mut adjacency_cache,
543 true,
544 )?;
545 if !include_tests {
546 filter_call_tree_tests(&mut tree);
547 }
548 Ok(tree)
549}
550
551pub fn impact_result(
552 store: &impl CallGraphRead,
553 file: &Path,
554 symbol: &str,
555 depth: usize,
556 include_tests: bool,
557) -> StoreAdapterResult<StoreImpactResult> {
558 let target = resolve_symbol_query(store, file, symbol)?;
559 let effective_depth = depth.max(1);
560 let mut visited = HashSet::new();
561 let mut sites = Vec::new();
562 let mut depth_limited = false;
563 let mut truncated = 0usize;
564
565 collect_callers_recursive(
566 store,
567 &target.representative.file,
568 &target.representative.symbol,
569 effective_depth,
570 0,
571 &mut visited,
572 &mut sites,
573 &mut depth_limited,
574 &mut truncated,
575 )?;
576
577 let mut sites = dedup_call_sites(sites);
578 sites.sort_by(|left, right| {
579 left.caller
580 .file
581 .cmp(&right.caller.file)
582 .then(left.line.cmp(&right.line))
583 .then(left.caller.symbol.cmp(&right.caller.symbol))
584 });
585 let total_affected = sites.len();
586 let hidden_tests = sites
587 .iter()
588 .filter(|site| callsite_is_from_test(site))
589 .count();
590 let summarize = total_affected > HUB_SUMMARY_THRESHOLD;
591 let affected_files = sites
592 .iter()
593 .map(|site| site.caller.file.clone())
594 .collect::<BTreeSet<_>>()
595 .len();
596 let visible_sites = sites
597 .into_iter()
598 .filter(|site| include_tests || !callsite_is_from_test(site))
599 .collect::<Vec<_>>();
600 let visible_sites = if summarize {
601 dedup_sites_for_summary(visible_sites)
602 .into_iter()
603 .take(HUB_SUMMARY_LIMIT)
604 .collect::<Vec<_>>()
605 } else {
606 visible_sites
607 };
608 let hub_summary = if summarize {
609 Some(if include_tests {
610 included_summary(
611 "affected callers",
612 total_affected,
613 hidden_tests,
614 visible_sites.len(),
615 )
616 } else {
617 test_hidden_summary(
618 "affected callers",
619 total_affected,
620 hidden_tests,
621 visible_sites.len(),
622 )
623 })
624 } else {
625 None
626 };
627 let target_signature = target.representative.signature.clone();
628 let target_parameters = target_signature
629 .as_deref()
630 .map(|signature| callgraph::extract_parameters(signature, target.representative.lang))
631 .unwrap_or_default();
632
633 let mut callers = Vec::new();
634 for site in visible_sites {
635 callers.push(StoreImpactCaller {
636 caller_symbol: site.caller.symbol.clone(),
637 caller_file: site.caller.file.clone(),
638 line: site.line,
639 signature: site.caller.signature.clone(),
640 is_entry_point: site.caller.is_entry_point,
641 call_expression: read_source_line(
642 &store.project_root().join(&site.caller.file),
643 site.line,
644 ),
645 parameters: site
646 .caller
647 .signature
648 .as_deref()
649 .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
650 .unwrap_or_default(),
651 approximate: edge_approximate(&site),
652 resolved_by: edge_resolved_by(&site),
653 });
654 }
655 callers.sort_by(|left, right| {
656 left.caller_file
657 .cmp(&right.caller_file)
658 .then(left.line.cmp(&right.line))
659 });
660
661 Ok(StoreImpactResult {
662 symbol: target.representative.symbol,
663 file: target.representative.file,
664 signature: target_signature,
665 parameters: target_parameters,
666 total_affected,
667 affected_files,
668 callers,
669 hub_summary,
670 depth_limited,
671 truncated,
672 })
673}
674
675pub fn trace_to_result(
676 store: &impl CallGraphRead,
677 file: &Path,
678 symbol: &str,
679 max_depth: usize,
680 include_tests: bool,
681) -> StoreAdapterResult<StoreTraceToResult> {
682 trace_to_result_with_budget(
683 store,
684 file,
685 symbol,
686 max_depth,
687 include_tests,
688 TRACE_TO_EXPANSION_BUDGET,
689 )
690 .map(|(result, _)| result)
691}
692
693fn trace_to_result_with_budget(
694 store: &impl CallGraphRead,
695 file: &Path,
696 symbol: &str,
697 max_depth: usize,
698 include_tests: bool,
699 expansion_budget: usize,
700) -> StoreAdapterResult<(StoreTraceToResult, usize)> {
701 let target = resolve_symbol_query(store, file, symbol)?;
702 let effective_max = if max_depth == 0 { 10 } else { max_depth };
703
704 let initial = vec![TraceElem {
705 node: target.representative.clone(),
706 edge: EdgeMarker::default(),
707 }];
708 let mut retained_paths = Vec::new();
709 let mut total_paths = 0usize;
710 let mut hidden_tests = 0usize;
711 if target.representative.is_entry_point {
712 total_paths = 1;
713 let path = store_trace_path(&initial);
714 if trace_path_starts_in_test(&path) {
715 hidden_tests = 1;
716 }
717 if include_tests || hidden_tests == 0 {
718 retain_trace_path(&mut retained_paths, path);
719 }
720 }
721
722 let mut queue = vec![(initial, 0usize)];
723 let mut max_depth_reached = false;
724 let mut truncated_paths = 0usize;
725 let mut expansions = 0usize;
726 let mut budget_exhausted = false;
727 let mut callers_by_symbol: HashMap<(String, String), Vec<StoreCallSite>> = HashMap::new();
728
729 'traversal: while let Some((path, depth)) = queue.pop() {
730 if expansions >= expansion_budget {
731 budget_exhausted = true;
732 break;
733 }
734 expansions += 1;
735 if depth >= effective_max {
736 max_depth_reached = true;
737 continue;
738 }
739 let Some(current) = path.last() else {
740 continue;
741 };
742 let caller_key = (current.node.file.clone(), current.node.symbol.clone());
743 if let std::collections::hash_map::Entry::Vacant(entry) =
744 callers_by_symbol.entry(caller_key.clone())
745 {
746 let callers =
747 dedup_call_sites(store.direct_callers_of(Path::new(&caller_key.0), &caller_key.1)?);
748 entry.insert(callers);
749 }
750 let callers = callers_by_symbol
751 .get(&caller_key)
752 .expect("trace caller cache populated above");
753 if callers.is_empty() {
754 if path.len() > 1 {
755 truncated_paths += 1;
756 }
757 continue;
758 }
759
760 let mut has_new_path = false;
761 for site in callers {
762 if path.iter().any(|elem| {
763 elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
764 }) {
765 continue;
766 }
767 has_new_path = true;
768 let mut next_path = path.clone();
769 if let Some(current) = next_path.last_mut() {
770 current.edge = edge_marker(&site);
771 }
772 next_path.push(TraceElem {
773 node: site.caller.clone(),
774 edge: EdgeMarker::default(),
775 });
776 if site.caller.is_entry_point {
777 total_paths = total_paths.saturating_add(1);
778 let completed = store_trace_path(&next_path);
779 let from_test = trace_path_starts_in_test(&completed);
780 if from_test {
781 hidden_tests = hidden_tests.saturating_add(1);
782 }
783 if include_tests || !from_test {
784 retain_trace_path(&mut retained_paths, completed);
785 }
786 }
787 if expansions.saturating_add(queue.len()) >= expansion_budget {
790 budget_exhausted = true;
791 break 'traversal;
792 }
793 queue.push((next_path, depth + 1));
794 }
795 if !has_new_path && path.len() > 1 {
796 truncated_paths += 1;
797 }
798 }
799
800 retained_paths.sort_by(trace_path_order);
801 let summarize = budget_exhausted || total_paths > HUB_SUMMARY_THRESHOLD;
802 let paths = if summarize {
803 dedup_paths_for_summary(retained_paths)
804 .into_iter()
805 .take(HUB_SUMMARY_LIMIT)
806 .collect::<Vec<_>>()
807 } else {
808 retained_paths
809 };
810 let hub_summary = if summarize {
811 Some(if budget_exhausted {
812 lower_bound_trace_summary(total_paths, hidden_tests, paths.len(), include_tests)
813 } else if include_tests {
814 included_summary("paths", total_paths, hidden_tests, paths.len())
815 } else {
816 test_hidden_summary("paths", total_paths, hidden_tests, paths.len())
817 })
818 } else {
819 None
820 };
821
822 let entry_points_found = paths
823 .iter()
824 .filter_map(|path| path.hops.first())
825 .filter(|hop| hop.is_entry_point)
826 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
827 .collect::<HashSet<_>>()
828 .len();
829
830 Ok((
831 StoreTraceToResult {
832 target_symbol: target.representative.symbol,
833 target_file: target.representative.file,
834 total_paths,
835 total_paths_is_lower_bound: budget_exhausted,
836 hub_summary,
837 paths,
838 entry_points_found,
839 max_depth_reached,
840 truncated_paths,
841 },
842 expansions,
843 ))
844}
845
846pub fn ensure_symbol_resolves(
847 store: &impl CallGraphRead,
848 file: &Path,
849 symbol: &str,
850) -> StoreAdapterResult<()> {
851 resolve_symbol_query(store, file, symbol).map(|_| ())
852}
853
854pub fn trace_to_symbol_candidates(
855 store: &impl CallGraphRead,
856 to_symbol: &str,
857) -> StoreAdapterResult<Vec<TraceToSymbolCandidate>> {
858 store.trace_to_symbol_candidates(to_symbol)
859}
860
861pub fn trace_to_symbol_result(
862 store: &impl CallGraphRead,
863 file: &Path,
864 symbol: &str,
865 to_symbol: &str,
866 to_file: Option<&Path>,
867 max_depth: usize,
868 include_tests: bool,
869) -> StoreAdapterResult<StoreTraceToSymbolResult> {
870 let origin = resolve_symbol_query(store, file, symbol)?;
871 let target_file = to_file.map(|path| relative_file(store, path));
872 let effective_max = if max_depth == 0 {
873 10
874 } else {
875 max_depth.min(16)
876 };
877
878 let start_hop = trace_to_symbol_hop(&origin.representative);
879 if trace_to_symbol_matches_target(
880 &origin.representative.file,
881 &origin.representative.symbol,
882 to_symbol,
883 target_file.as_deref(),
884 ) {
885 return Ok(StoreTraceToSymbolResult {
886 path: Some(vec![start_hop]),
887 complete: true,
888 reason: None,
889 });
890 }
891
892 let mut queue = VecDeque::new();
893 queue.push_back((
894 origin.representative.file.clone(),
895 origin.representative.symbol.clone(),
896 vec![start_hop],
897 0usize,
898 ));
899 let mut visited = HashSet::new();
900 visited.insert((
901 origin.representative.file.clone(),
902 origin.representative.symbol.clone(),
903 ));
904 let mut max_depth_exhausted = false;
905
906 while let Some((current_file, current_symbol, path, depth)) = queue.pop_front() {
907 let callees = forward_resolved_callees(store, ¤t_file, ¤t_symbol)?;
908
909 if depth >= effective_max {
910 if callees
911 .iter()
912 .any(|(node, _)| !visited.contains(&(node.file.clone(), node.symbol.clone())))
913 {
914 max_depth_exhausted = true;
915 }
916 continue;
917 }
918
919 for (callee, edge) in callees {
920 if !include_tests && is_test_file(&callee.file) {
921 continue;
922 }
923 if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
924 continue;
925 }
926 let mut next_path = path.clone();
927 next_path.push(trace_to_symbol_hop_with_edge(&callee, edge));
928 if trace_to_symbol_matches_target(
929 &callee.file,
930 &callee.symbol,
931 to_symbol,
932 target_file.as_deref(),
933 ) {
934 return Ok(StoreTraceToSymbolResult {
935 path: Some(next_path),
936 complete: true,
937 reason: None,
938 });
939 }
940 queue.push_back((callee.file, callee.symbol, next_path, depth + 1));
941 }
942 }
943
944 if max_depth_exhausted {
945 Ok(StoreTraceToSymbolResult {
946 path: None,
947 complete: false,
948 reason: Some("max_depth_exhausted".to_string()),
949 })
950 } else {
951 Ok(StoreTraceToSymbolResult {
952 path: None,
953 complete: true,
954 reason: Some("no_path_found".to_string()),
955 })
956 }
957}
958
959#[derive(Debug, Default)]
960struct TrackedBindings {
961 approximate_by_name: HashMap<String, bool>,
962}
963
964struct AssignmentInfo {
965 binding: String,
966 hop_variable: String,
967 line: u32,
968 approximate: bool,
969 stop_after_hop: bool,
970}
971
972impl TrackedBindings {
973 fn with_origin(name: &str, approximate: bool) -> Self {
974 let mut bindings = Self::default();
975 bindings.track(name.to_string(), approximate);
976 bindings
977 }
978
979 fn contains(&self, name: &str) -> bool {
980 self.approximate_by_name.contains_key(name)
981 }
982
983 fn approximation(&self, name: &str) -> Option<bool> {
984 self.approximate_by_name.get(name).copied()
985 }
986
987 fn track(&mut self, name: String, approximate: bool) {
988 self.approximate_by_name.insert(name, approximate);
989 }
990
991 fn kill(&mut self, name: &str) {
992 self.approximate_by_name.remove(name);
993 }
994
995 fn mark_approximate(&mut self, name: &str) {
996 if let Some(approximate) = self.approximate_by_name.get_mut(name) {
997 *approximate = true;
998 }
999 }
1000}
1001
1002pub fn trace_data_result(
1003 store: &impl CallGraphRead,
1004 file: &Path,
1005 symbol: &str,
1006 expression: &str,
1007 max_depth: usize,
1008 symbol_cache: SharedSymbolCache,
1009) -> StoreAdapterResult<callgraph::TraceDataResult> {
1010 let origin_path = absolute_file(store, file);
1011 let origin_file = relative_file(store, &origin_path);
1012 let origin_symbol = resolve_symbol_query_with_cache(&origin_path, symbol, &symbol_cache)?;
1013
1014 let mut hops = Vec::new();
1015 let mut depth_limited = false;
1016 let mut visited = HashSet::new();
1017 trace_data_inner(
1018 store,
1019 &symbol_cache,
1020 &origin_path,
1021 &origin_symbol,
1022 expression,
1023 false,
1024 max_depth,
1025 0,
1026 &mut hops,
1027 &mut depth_limited,
1028 &mut visited,
1029 )?;
1030
1031 Ok(callgraph::TraceDataResult {
1032 expression: expression.to_string(),
1033 origin_file,
1034 origin_symbol,
1035 hops,
1036 depth_limited,
1037 })
1038}
1039
1040#[allow(clippy::too_many_arguments)]
1041fn trace_data_inner(
1042 store: &impl CallGraphRead,
1043 symbol_cache: &SharedSymbolCache,
1044 file: &Path,
1045 symbol: &str,
1046 tracking_name: &str,
1047 tracking_approximate: bool,
1048 max_depth: usize,
1049 current_depth: usize,
1050 hops: &mut Vec<callgraph::DataFlowHop>,
1051 depth_limited: &mut bool,
1052 visited: &mut HashSet<(String, String, String, bool)>,
1053) -> StoreAdapterResult<()> {
1054 let rel_file = relative_file(store, file);
1055 let visit_key = (
1056 rel_file.clone(),
1057 symbol.to_string(),
1058 tracking_name.to_string(),
1059 tracking_approximate,
1060 );
1061 if visited.contains(&visit_key) {
1062 return Ok(());
1063 }
1064 visited.insert(visit_key);
1065
1066 let current = resolve_exact_symbol(store, &rel_file, symbol, None)?
1067 .ok_or_else(|| CallGraphStoreError::StaleFiles(vec![rel_file.clone()]))?;
1068 let current_calls = trace_forward_calls_for_nodes(store, ¤t.nodes)?;
1069
1070 let source = std::fs::read_to_string(file)?;
1073 let Some(lang) = detect_language(file) else {
1074 return Ok(());
1075 };
1076 let grammar = grammar_for(lang);
1077 let mut parser = Parser::new();
1078 parser
1079 .set_language(&grammar)
1080 .map_err(|error| AftError::ParseError {
1081 message: format!("grammar init failed for {:?}: {}", lang, error),
1082 })?;
1083 let tree = parser
1084 .parse(&source, None)
1085 .ok_or_else(|| AftError::ParseError {
1086 message: format!("parse failed for {}", file.display()),
1087 })?;
1088 let symbols = extract_symbols_from_tree(&source, &tree, lang)?;
1089 let sym_info = symbols
1090 .iter()
1091 .find(|candidate| {
1092 symbol_identity_from_cache(candidate) == symbol || candidate.name == symbol
1093 })
1094 .ok_or_else(|| CallGraphStoreError::StaleFiles(vec![rel_file.clone()]))?;
1095
1096 let body_start = line_col_to_byte(&source, sym_info.range.start_line, sym_info.range.start_col);
1097 let body_end = line_col_to_byte(&source, sym_info.range.end_line, sym_info.range.end_col);
1098 let Some(body_node) = find_node_covering_range(tree.root_node(), body_start, body_end) else {
1099 return Ok(());
1100 };
1101
1102 let mut tracked = TrackedBindings::with_origin(tracking_name, tracking_approximate);
1103 walk_for_data_flow(
1104 store,
1105 symbol_cache,
1106 body_node,
1107 &source,
1108 ¤t_calls,
1109 &mut tracked,
1110 symbol,
1111 &rel_file,
1112 max_depth,
1113 current_depth,
1114 false,
1115 false,
1116 hops,
1117 depth_limited,
1118 visited,
1119 )
1120}
1121
1122#[allow(clippy::too_many_arguments)]
1123fn walk_for_data_flow(
1124 store: &impl CallGraphRead,
1125 symbol_cache: &SharedSymbolCache,
1126 node: Node<'_>,
1127 source: &str,
1128 current_calls: &[TraceForwardCall],
1129 tracked: &mut TrackedBindings,
1130 symbol: &str,
1131 rel_file: &str,
1132 max_depth: usize,
1133 current_depth: usize,
1134 control_flow_uncertain: bool,
1135 origin_function_seen: bool,
1136 hops: &mut Vec<callgraph::DataFlowHop>,
1137 depth_limited: &mut bool,
1138 visited: &mut HashSet<(String, String, String, bool)>,
1139) -> StoreAdapterResult<()> {
1140 let kind = node.kind();
1141 let is_var_decl = matches!(
1142 kind,
1143 "variable_declarator"
1144 | "assignment_expression"
1145 | "augmented_assignment_expression"
1146 | "assignment"
1147 | "let_declaration"
1148 | "short_var_declaration"
1149 );
1150
1151 if is_var_decl {
1152 if let Some(assignment) = extract_assignment_info(node, source, tracked) {
1153 let approximate = assignment.approximate || control_flow_uncertain;
1154 hops.push(callgraph::DataFlowHop {
1155 file: rel_file.to_string(),
1156 symbol: symbol.to_string(),
1157 variable: assignment.hop_variable,
1158 line: assignment.line,
1159 flow_type: "assignment".to_string(),
1160 approximate,
1161 });
1162 tracked.track(assignment.binding, approximate);
1163 if assignment.stop_after_hop {
1164 return Ok(());
1165 }
1166 } else if let Some(overwritten_name) = plain_assignment_target(node, source) {
1167 if tracked.contains(&overwritten_name) {
1168 if control_flow_uncertain {
1169 tracked.mark_approximate(&overwritten_name);
1172 } else {
1173 tracked.kill(&overwritten_name);
1174 }
1175 }
1176 }
1177 }
1178
1179 if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
1180 check_call_for_data_flow(
1181 store,
1182 symbol_cache,
1183 node,
1184 source,
1185 current_calls,
1186 tracked,
1187 symbol,
1188 rel_file,
1189 max_depth,
1190 current_depth,
1191 hops,
1192 depth_limited,
1193 visited,
1194 )?;
1195 }
1196
1197 let is_function = function_container(kind);
1198 let descendants_uncertain = control_flow_uncertain
1199 || conditional_flow_container(kind)
1200 || (is_function && origin_function_seen);
1201 let descendants_origin_function_seen = origin_function_seen || is_function;
1202 let mut cursor = node.walk();
1203 if cursor.goto_first_child() {
1204 loop {
1205 walk_for_data_flow(
1206 store,
1207 symbol_cache,
1208 cursor.node(),
1209 source,
1210 current_calls,
1211 tracked,
1212 symbol,
1213 rel_file,
1214 max_depth,
1215 current_depth,
1216 descendants_uncertain,
1217 descendants_origin_function_seen,
1218 hops,
1219 depth_limited,
1220 visited,
1221 )?;
1222 if !cursor.goto_next_sibling() {
1223 break;
1224 }
1225 }
1226 }
1227 Ok(())
1228}
1229
1230fn extract_assignment_info(
1231 node: Node<'_>,
1232 source: &str,
1233 tracked: &TrackedBindings,
1234) -> Option<AssignmentInfo> {
1235 let kind = node.kind();
1236 let line = node.start_position().row as u32 + 1;
1237
1238 let (name_node, value_node) = match kind {
1239 "variable_declarator" => (
1240 node.child_by_field_name("name")?,
1241 node.child_by_field_name("value")?,
1242 ),
1243 "assignment_expression" | "augmented_assignment_expression" | "assignment" => (
1244 node.child_by_field_name("left")?,
1245 node.child_by_field_name("right")?,
1246 ),
1247 "let_declaration" | "short_var_declaration" => (
1248 node.child_by_field_name("pattern")
1249 .or_else(|| node.child_by_field_name("left"))?,
1250 node.child_by_field_name("value")
1251 .or_else(|| node.child_by_field_name("right"))?,
1252 ),
1253 _ => return None,
1254 };
1255
1256 let binding = trace_node_text(name_node, source);
1257 if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
1258 tracked_reference_approximation(value_node, source, tracked)?;
1259 return Some(AssignmentInfo {
1260 binding: binding.clone(),
1261 hop_variable: binding,
1262 line,
1263 approximate: true,
1264 stop_after_hop: true,
1265 });
1266 }
1267
1268 let source_approximate = if kind == "augmented_assignment_expression" {
1269 merge_reference_approximation(
1270 tracked_reference_approximation(name_node, source, tracked),
1271 tracked_reference_approximation(value_node, source, tracked),
1272 )?
1273 } else {
1274 tracked_reference_approximation(value_node, source, tracked)?
1275 };
1276
1277 Some(AssignmentInfo {
1278 binding: binding.clone(),
1279 hop_variable: binding,
1280 line,
1281 approximate: source_approximate,
1282 stop_after_hop: false,
1283 })
1284}
1285
1286fn merge_reference_approximation(left: Option<bool>, right: Option<bool>) -> Option<bool> {
1287 match (left, right) {
1288 (Some(left), Some(right)) => Some(left && right),
1289 (Some(approximate), None) | (None, Some(approximate)) => Some(approximate),
1290 (None, None) => None,
1291 }
1292}
1293
1294fn tracked_reference_approximation(
1295 node: Node<'_>,
1296 source: &str,
1297 tracked: &TrackedBindings,
1298) -> Option<bool> {
1299 let mut approximation = if is_identifier_reference(node) {
1300 tracked.approximation(&trace_node_text(node, source))
1301 } else {
1302 None
1303 };
1304
1305 let mut cursor = node.walk();
1306 if cursor.goto_first_child() {
1307 loop {
1308 approximation = merge_reference_approximation(
1309 approximation,
1310 tracked_reference_approximation(cursor.node(), source, tracked),
1311 );
1312 if !cursor.goto_next_sibling() {
1313 break;
1314 }
1315 }
1316 }
1317 approximation
1318}
1319
1320fn is_identifier_reference(node: Node<'_>) -> bool {
1321 if !matches!(
1322 node.kind(),
1323 "identifier" | "simple_identifier" | "variable_name" | "shorthand_property_identifier"
1324 ) {
1325 return false;
1326 }
1327
1328 let Some(parent) = node.parent() else {
1329 return true;
1330 };
1331 !["property", "attribute", "field"].iter().any(|field| {
1332 parent
1333 .child_by_field_name(field)
1334 .is_some_and(|child| child.id() == node.id())
1335 })
1336}
1337
1338fn plain_assignment_target(node: Node<'_>, source: &str) -> Option<String> {
1339 if node.kind() == "augmented_assignment_expression" {
1340 return None;
1341 }
1342 let target = match node.kind() {
1343 "assignment_expression" | "assignment" => node.child_by_field_name("left")?,
1344 _ => return None,
1345 };
1346 is_identifier_reference(target).then(|| trace_node_text(target, source))
1347}
1348
1349fn conditional_flow_container(kind: &str) -> bool {
1350 matches!(
1351 kind,
1352 "if_statement"
1353 | "else_clause"
1354 | "conditional_expression"
1355 | "ternary_expression"
1356 | "switch_statement"
1357 | "switch_expression"
1358 | "match_expression"
1359 | "when_expression"
1360 | "for_statement"
1361 | "for_in_statement"
1362 | "for_each_statement"
1363 | "while_statement"
1364 | "do_statement"
1365 | "try_statement"
1366 | "catch_clause"
1367 | "finally_clause"
1368 )
1369}
1370
1371fn function_container(kind: &str) -> bool {
1372 matches!(
1373 kind,
1374 "function_declaration"
1375 | "function_expression"
1376 | "arrow_function"
1377 | "method_definition"
1378 | "lambda"
1379 | "lambda_expression"
1380 | "closure_expression"
1381 )
1382}
1383
1384#[allow(clippy::too_many_arguments)]
1385fn check_call_for_data_flow(
1386 store: &impl CallGraphRead,
1387 symbol_cache: &SharedSymbolCache,
1388 node: Node<'_>,
1389 source: &str,
1390 current_calls: &[TraceForwardCall],
1391 tracked: &TrackedBindings,
1392 symbol: &str,
1393 rel_file: &str,
1394 max_depth: usize,
1395 current_depth: usize,
1396 hops: &mut Vec<callgraph::DataFlowHop>,
1397 depth_limited: &mut bool,
1398 visited: &mut HashSet<(String, String, String, bool)>,
1399) -> StoreAdapterResult<()> {
1400 let args_node =
1401 find_child_by_kind(node, "arguments").or_else(|| find_child_by_kind(node, "argument_list"));
1402 let Some(args_node) = args_node else {
1403 return Ok(());
1404 };
1405
1406 let mut arg_positions = Vec::new();
1407 let mut arg_idx = 0usize;
1408 let mut cursor = args_node.walk();
1409 if cursor.goto_first_child() {
1410 loop {
1411 let child = cursor.node();
1412 let child_kind = child.kind();
1413 if child_kind == "(" || child_kind == ")" || child_kind == "," {
1414 if !cursor.goto_next_sibling() {
1415 break;
1416 }
1417 continue;
1418 }
1419
1420 let arg_text = trace_node_text(child, source);
1421 if child_kind == "spread_element" || child_kind == "dictionary_splat" {
1422 if tracked_reference_approximation(child, source, tracked).is_some() {
1423 hops.push(callgraph::DataFlowHop {
1424 file: rel_file.to_string(),
1425 symbol: symbol.to_string(),
1426 variable: arg_text,
1427 line: child.start_position().row as u32 + 1,
1428 flow_type: "parameter".to_string(),
1429 approximate: true,
1430 });
1431 }
1432 if !cursor.goto_next_sibling() {
1433 break;
1434 }
1435 arg_idx += 1;
1436 continue;
1437 }
1438
1439 if let Some(approximate) = tracked.approximation(&arg_text) {
1440 arg_positions.push((arg_idx, arg_text, approximate));
1441 }
1442
1443 arg_idx += 1;
1444 if !cursor.goto_next_sibling() {
1445 break;
1446 }
1447 }
1448 }
1449
1450 if arg_positions.is_empty() {
1451 return Ok(());
1452 }
1453
1454 let matched_call = current_calls
1455 .iter()
1456 .find(|call| call.matches_position(node.start_byte(), node.end_byte()));
1457
1458 match matched_call {
1459 Some(TraceForwardCall::Resolved(site)) => {
1460 let Some(target) = trace_target_node(store, site)? else {
1461 return Ok(());
1462 };
1463 if target.file != rel_file && current_depth + 1 > max_depth {
1464 *depth_limited = true;
1465 return Ok(());
1466 }
1467 let params = target
1468 .signature
1469 .as_deref()
1470 .map(|signature| callgraph::extract_parameters(signature, target.lang))
1471 .unwrap_or_default();
1472 let target_file = store.project_root().join(&target.file);
1473 for (pos, _tracked, approximate) in &arg_positions {
1474 if let Some(param_name) = params.get(*pos) {
1475 hops.push(callgraph::DataFlowHop {
1476 file: target.file.clone(),
1477 symbol: target.symbol.clone(),
1478 variable: param_name.clone(),
1479 line: target.line,
1480 flow_type: "parameter".to_string(),
1481 approximate: *approximate,
1482 });
1483 trace_data_inner(
1484 store,
1485 symbol_cache,
1486 &target_file,
1487 &target.symbol,
1488 param_name,
1489 *approximate,
1490 max_depth,
1491 current_depth + 1,
1492 hops,
1493 depth_limited,
1494 visited,
1495 )?;
1496 }
1497 }
1498 }
1499 Some(TraceForwardCall::Unresolved(call)) => {
1500 push_unresolved_parameter_hops(hops, rel_file, &call.symbol, &arg_positions, node);
1501 }
1502 None => {
1503 let (_full_callee, short_callee) = extract_callee_names(node, source);
1504 if let Some(callee_name) = short_callee {
1505 push_unresolved_parameter_hops(hops, rel_file, &callee_name, &arg_positions, node);
1506 }
1507 }
1508 }
1509
1510 Ok(())
1511}
1512
1513fn push_unresolved_parameter_hops(
1514 hops: &mut Vec<callgraph::DataFlowHop>,
1515 rel_file: &str,
1516 callee_name: &str,
1517 arg_positions: &[(usize, String, bool)],
1518 call_node: Node<'_>,
1519) {
1520 for (_pos, tracked, _approximate) in arg_positions {
1521 hops.push(callgraph::DataFlowHop {
1522 file: rel_file.to_string(),
1523 symbol: callee_name.to_string(),
1524 variable: tracked.clone(),
1525 line: call_node.start_position().row as u32 + 1,
1526 flow_type: "parameter".to_string(),
1527 approximate: true,
1528 });
1529 }
1530}
1531
1532fn trace_target_node(
1533 store: &impl CallGraphRead,
1534 site: &StoreCallSite,
1535) -> StoreAdapterResult<Option<StoreNode>> {
1536 if let Some(target) = &site.target {
1537 return Ok(Some(target.clone()));
1538 }
1539 resolve_exact_symbol(store, &site.target_file, &site.target_symbol, None)
1540 .map(|resolved| resolved.map(|symbol| symbol.representative))
1541}
1542
1543fn trace_forward_calls_for_nodes(
1544 store: &impl CallGraphRead,
1545 nodes: &[StoreNode],
1546) -> StoreAdapterResult<Vec<TraceForwardCall>> {
1547 let mut calls = Vec::new();
1548 for node in nodes {
1549 calls.extend(
1550 store
1551 .outgoing_calls_of(node)?
1552 .into_iter()
1553 .filter(|site| site.resolved_by() == TRACE_DATA_RESOLVER_PROVENANCE)
1554 .map(TraceForwardCall::Resolved),
1555 );
1556 calls.extend(
1557 store
1558 .resolved_self_calls_of(node)?
1559 .into_iter()
1560 .filter(|site| site.resolved_by() == TRACE_DATA_RESOLVER_PROVENANCE)
1561 .map(TraceForwardCall::Resolved),
1562 );
1563 calls.extend(
1564 store
1565 .unresolved_calls_of(node)?
1566 .into_iter()
1567 .map(TraceForwardCall::Unresolved),
1568 );
1569 }
1570 calls.sort_by(|left, right| {
1571 left.byte_start()
1572 .cmp(&right.byte_start())
1573 .then(left.byte_end().cmp(&right.byte_end()))
1574 .then(left.line().cmp(&right.line()))
1575 });
1576 Ok(calls)
1577}
1578
1579fn resolve_symbol_query_with_cache(
1580 file: &Path,
1581 symbol: &str,
1582 symbol_cache: &SharedSymbolCache,
1583) -> StoreAdapterResult<String> {
1584 let mut parser = FileParser::with_symbol_cache(symbol_cache.clone());
1585 let symbols = parser.extract_symbols(file)?;
1586 let candidates = symbol_query_candidates_from_symbols(&symbols, symbol);
1587 match candidates.as_slice() {
1588 [candidate] => Ok(candidate.clone()),
1589 [] => Err(AftError::SymbolNotFound {
1590 name: symbol.to_string(),
1591 file: file.display().to_string(),
1592 }
1593 .into()),
1594 _ => Err(AftError::AmbiguousSymbol {
1595 name: symbol.to_string(),
1596 candidates,
1597 }
1598 .into()),
1599 }
1600}
1601
1602fn symbol_query_candidates_from_symbols(symbols: &[Symbol], symbol_name: &str) -> Vec<String> {
1603 let mut seen = HashSet::new();
1604 let mut candidates = Vec::new();
1605 let qualified_query = symbol_name.contains("::");
1606
1607 let mut consider = |candidate: String| {
1608 let matches = if qualified_query {
1609 candidate == symbol_name
1610 } else {
1611 candidate == symbol_name || unqualified_name(&candidate) == symbol_name
1612 };
1613 if matches && seen.insert(candidate.clone()) {
1614 candidates.push(candidate);
1615 }
1616 };
1617
1618 for symbol in symbols {
1619 consider(symbol_identity_from_cache(symbol));
1620 if symbol.exported {
1621 consider(symbol.name.clone());
1622 }
1623 }
1624
1625 candidates.sort();
1626 candidates
1627}
1628
1629fn symbol_identity_from_cache(symbol: &Symbol) -> String {
1630 if symbol.scope_chain.is_empty() {
1631 symbol.name.clone()
1632 } else {
1633 format!("{}::{}", symbol.scope_chain.join("::"), symbol.name)
1634 }
1635}
1636
1637fn trace_node_text(node: Node<'_>, source: &str) -> String {
1638 source[node.start_byte()..node.end_byte()].to_string()
1639}
1640
1641fn find_node_covering_range(root: Node<'_>, start: usize, end: usize) -> Option<Node<'_>> {
1642 let mut best = None;
1643 let mut cursor = root.walk();
1644
1645 fn walk_covering<'a>(
1646 cursor: &mut tree_sitter::TreeCursor<'a>,
1647 start: usize,
1648 end: usize,
1649 best: &mut Option<Node<'a>>,
1650 ) {
1651 let node = cursor.node();
1652 if node.start_byte() <= start && node.end_byte() >= end {
1653 *best = Some(node);
1654 if cursor.goto_first_child() {
1655 loop {
1656 walk_covering(cursor, start, end, best);
1657 if !cursor.goto_next_sibling() {
1658 break;
1659 }
1660 }
1661 cursor.goto_parent();
1662 }
1663 }
1664 }
1665
1666 walk_covering(&mut cursor, start, end, &mut best);
1667 best
1668}
1669
1670fn find_child_by_kind<'tree>(node: Node<'tree>, kind: &str) -> Option<Node<'tree>> {
1671 let mut cursor = node.walk();
1672 if cursor.goto_first_child() {
1673 loop {
1674 if cursor.node().kind() == kind {
1675 return Some(cursor.node());
1676 }
1677 if !cursor.goto_next_sibling() {
1678 break;
1679 }
1680 }
1681 }
1682 None
1683}
1684
1685fn extract_callee_names(node: Node<'_>, source: &str) -> (Option<String>, Option<String>) {
1686 let Some(callee) = node.child_by_field_name("function") else {
1687 return (None, None);
1688 };
1689 let full = trace_node_text(callee, source);
1690 let short = if full.contains('.') {
1691 full.rsplit('.').next().unwrap_or(&full).to_string()
1692 } else {
1693 full.clone()
1694 };
1695 (Some(full), Some(short))
1696}
1697
1698pub fn store_error_response(req_id: &str, operation: &str, error: CallGraphStoreError) -> Response {
1699 match error {
1700 CallGraphStoreError::Aft(error) => Response::error(req_id, error.code(), error.to_string()),
1701 CallGraphStoreError::Unavailable(message) => Response::error(
1702 req_id,
1703 "callgraph_unavailable",
1704 format!("{operation}: persisted callgraph store unavailable: {message}"),
1705 ),
1706 CallGraphStoreError::StaleFiles(files) => Response::error(
1707 req_id,
1708 "callgraph_stale",
1709 format!(
1710 "{operation}: persisted callgraph store has stale files: {}",
1711 files.join(", ")
1712 ),
1713 ),
1714 other => Response::error(
1715 req_id,
1716 "callgraph_store_error",
1717 format!("{operation}: persisted callgraph store error: {other}"),
1718 ),
1719 }
1720}
1721
1722pub fn building_response(req_id: &str, operation: &str) -> Response {
1726 Response::error(
1727 req_id,
1728 "callgraph_building",
1729 format!("{operation}: callgraph store is building in the background; retry shortly"),
1730 )
1731}
1732
1733pub fn unavailable_response(req_id: &str, operation: &str, worktree: bool) -> Response {
1734 let message = if worktree {
1735 format!(
1736 "{operation}: persisted callgraph store is unavailable in this read-only worktree; run a callgraph operation in the main checkout to build it first"
1737 )
1738 } else {
1739 format!("{operation}: project not configured — send 'configure' first")
1740 };
1741 let code = if worktree {
1742 "callgraph_unavailable"
1743 } else {
1744 "not_configured"
1745 };
1746 Response::error(req_id, code, message)
1747}
1748
1749fn resolve_symbol_query(
1750 store: &impl CallGraphRead,
1751 file: &Path,
1752 symbol: &str,
1753) -> StoreAdapterResult<ResolvedStoreSymbol> {
1754 let nodes = store.nodes_for(file, symbol)?;
1755 collapse_symbol_nodes(store, file, symbol, nodes)
1756}
1757
1758fn resolve_exact_symbol(
1759 store: &impl CallGraphRead,
1760 file: &str,
1761 symbol: &str,
1762 fallback: Option<StoreNode>,
1763) -> StoreAdapterResult<Option<ResolvedStoreSymbol>> {
1764 let nodes = store
1765 .nodes_for(Path::new(file), symbol)?
1766 .into_iter()
1767 .filter(|node| node.symbol == symbol)
1768 .collect::<Vec<_>>();
1769 if nodes.is_empty() {
1770 return Ok(fallback.map(|node| ResolvedStoreSymbol {
1771 representative: node.clone(),
1772 nodes: vec![node],
1773 }));
1774 }
1775 Ok(Some(collapse_exact_nodes(nodes)))
1776}
1777
1778fn collapse_symbol_nodes(
1779 store: &impl CallGraphRead,
1780 file: &Path,
1781 query: &str,
1782 nodes: Vec<StoreNode>,
1783) -> StoreAdapterResult<ResolvedStoreSymbol> {
1784 let mut by_symbol: BTreeMap<String, Vec<StoreNode>> = BTreeMap::new();
1785 for node in nodes {
1786 by_symbol.entry(node.symbol.clone()).or_default().push(node);
1787 }
1788
1789 match by_symbol.len() {
1790 0 => Err(CallGraphStoreError::Aft(AftError::SymbolNotFound {
1791 name: query.to_string(),
1792 file: display_file_for_error(store, file),
1793 })),
1794 1 => Ok(collapse_exact_nodes(
1795 by_symbol.into_values().next().unwrap_or_default(),
1796 )),
1797 _ => Err(CallGraphStoreError::Aft(AftError::AmbiguousSymbol {
1798 name: query.to_string(),
1799 candidates: by_symbol.into_keys().collect(),
1800 })),
1801 }
1802}
1803
1804fn collapse_exact_nodes(mut nodes: Vec<StoreNode>) -> ResolvedStoreSymbol {
1805 nodes.sort_by(|left, right| {
1806 left.symbol
1807 .cmp(&right.symbol)
1808 .then(left.line.cmp(&right.line))
1809 .then(left.end_line.cmp(&right.end_line))
1810 });
1811 let representative = nodes[0].clone();
1812 ResolvedStoreSymbol {
1813 representative,
1814 nodes,
1815 }
1816}
1817
1818#[allow(clippy::too_many_arguments)]
1819fn collect_callers_recursive(
1820 store: &impl CallGraphRead,
1821 file: &str,
1822 symbol: &str,
1823 max_depth: usize,
1824 current_depth: usize,
1825 visited: &mut HashSet<(String, String)>,
1826 result: &mut Vec<StoreCallSite>,
1827 depth_limited: &mut bool,
1828 truncated: &mut usize,
1829) -> StoreAdapterResult<()> {
1830 if current_depth >= max_depth {
1831 let target = (file.to_string(), symbol.to_string());
1832 let counts = store.direct_caller_counts_of(std::slice::from_ref(&target))?;
1833 let omitted = counts.get(&target).copied().unwrap_or_default();
1834 if omitted > 0 {
1835 *depth_limited = true;
1836 *truncated += omitted;
1837 }
1838 return Ok(());
1839 }
1840
1841 if !visited.insert((file.to_string(), symbol.to_string())) {
1842 return Ok(());
1843 }
1844
1845 let sites = store.direct_callers_of(Path::new(file), symbol)?;
1846 if sites.is_empty() {
1847 return Ok(());
1848 }
1849 if current_depth + 1 < max_depth {
1850 for site in sites {
1851 result.push(site.clone());
1852 collect_callers_recursive(
1853 store,
1854 &site.caller.file,
1855 &site.caller.symbol,
1856 max_depth,
1857 current_depth + 1,
1858 visited,
1859 result,
1860 depth_limited,
1861 truncated,
1862 )?;
1863 }
1864 } else {
1865 let boundary_targets = sites
1866 .iter()
1867 .map(|site| (site.caller.file.clone(), site.caller.symbol.clone()))
1868 .collect::<BTreeSet<_>>()
1869 .into_iter()
1870 .collect::<Vec<_>>();
1871 let boundary_counts = store.direct_caller_counts_of(&boundary_targets)?;
1872 for site in sites {
1873 result.push(site.clone());
1874 let key = (site.caller.file.clone(), site.caller.symbol.clone());
1875 let omitted = boundary_counts.get(&key).copied().unwrap_or_default();
1876 if omitted > 0 {
1877 *depth_limited = true;
1878 *truncated += omitted;
1879 }
1880 }
1881 }
1882 Ok(())
1883}
1884
1885#[allow(clippy::too_many_arguments)]
1886fn call_tree_inner(
1887 store: &impl CallGraphRead,
1888 current: &ResolvedStoreSymbol,
1889 max_depth: usize,
1890 current_depth: usize,
1891 visited: &mut HashSet<(String, String)>,
1892 adjacency_cache: &mut HashMap<(String, String), Vec<ForwardCall>>,
1893 memoize_adjacency: bool,
1894) -> StoreAdapterResult<StoreCallTreeNode> {
1895 let node = ¤t.representative;
1896 let visit_key = (node.file.clone(), node.symbol.clone());
1897 if visited.contains(&visit_key) {
1898 return Ok(StoreCallTreeNode {
1899 name: node.symbol.clone(),
1900 file: node.file.clone(),
1901 line: node.line,
1902 signature: node.signature.clone(),
1903 resolved: true,
1904 approximate: None,
1905 resolved_by: None,
1906 children: Vec::new(),
1907 depth_limited: false,
1908 truncated: 0,
1909 });
1910 }
1911 visited.insert(visit_key.clone());
1912
1913 let calls = if memoize_adjacency {
1916 if let Some(calls) = adjacency_cache.get(&visit_key) {
1917 calls.clone()
1918 } else {
1919 let calls = forward_calls_for_nodes(store, ¤t.nodes)?;
1920 adjacency_cache.insert(visit_key.clone(), calls.clone());
1921 calls
1922 }
1923 } else {
1924 forward_calls_for_nodes(store, ¤t.nodes)?
1925 };
1926 let mut children = Vec::new();
1927 let mut depth_limited = false;
1928 let mut truncated = 0usize;
1929
1930 if current_depth < max_depth {
1931 for call in calls {
1932 match call {
1933 ForwardCall::Resolved(site) => {
1934 let resolved = resolve_exact_symbol(
1935 store,
1936 &site.target_file,
1937 &site.target_symbol,
1938 site.target.clone(),
1939 )?;
1940 if let Some(child_symbol) = resolved {
1941 let mut child = call_tree_inner(
1942 store,
1943 &child_symbol,
1944 max_depth,
1945 current_depth + 1,
1946 visited,
1947 adjacency_cache,
1948 memoize_adjacency,
1949 )?;
1950 child.approximate = edge_approximate(&site);
1951 child.resolved_by = edge_resolved_by(&site);
1952 depth_limited |= child.depth_limited;
1953 truncated += child.truncated;
1954 children.push(child);
1955 } else {
1956 children.push(StoreCallTreeNode {
1957 name: site.target_symbol.clone(),
1958 file: site.target_file.clone(),
1959 line: site.line,
1960 signature: None,
1961 resolved: false,
1962 approximate: edge_approximate(&site),
1963 resolved_by: edge_resolved_by(&site),
1964 children: Vec::new(),
1965 depth_limited: false,
1966 truncated: 0,
1967 });
1968 }
1969 }
1970 ForwardCall::Unresolved(call) => children.push(StoreCallTreeNode {
1971 name: call.symbol,
1972 file: call.caller.file,
1973 line: call.line,
1974 signature: None,
1975 resolved: false,
1976 approximate: None,
1977 resolved_by: None,
1978 children: Vec::new(),
1979 depth_limited: false,
1980 truncated: 0,
1981 }),
1982 }
1983 }
1984 } else if !calls.is_empty() {
1985 depth_limited = true;
1986 truncated = calls.len();
1987 }
1988
1989 visited.remove(&visit_key);
1990 Ok(StoreCallTreeNode {
1991 name: node.symbol.clone(),
1992 file: node.file.clone(),
1993 line: node.line,
1994 signature: node.signature.clone(),
1995 resolved: true,
1996 approximate: None,
1997 resolved_by: None,
1998 children,
1999 depth_limited,
2000 truncated,
2001 })
2002}
2003
2004fn forward_calls_for_nodes(
2005 store: &impl CallGraphRead,
2006 nodes: &[StoreNode],
2007) -> StoreAdapterResult<Vec<ForwardCall>> {
2008 let mut calls = Vec::new();
2009 for node in nodes {
2010 calls.extend(
2011 store
2012 .outgoing_calls_of(node)?
2013 .into_iter()
2014 .map(ForwardCall::Resolved),
2015 );
2016 calls.extend(
2017 store
2018 .unresolved_calls_of(node)?
2019 .into_iter()
2020 .map(ForwardCall::Unresolved),
2021 );
2022 }
2023 calls.sort_by(|left, right| {
2024 left.byte_start()
2025 .cmp(&right.byte_start())
2026 .then(left.line().cmp(&right.line()))
2027 });
2028 let mut seen = BTreeSet::new();
2029 calls.retain(|call| seen.insert(call.call_site_key()));
2030 Ok(calls)
2031}
2032
2033fn forward_resolved_callees(
2034 store: &impl CallGraphRead,
2035 file: &str,
2036 symbol: &str,
2037) -> StoreAdapterResult<Vec<(StoreNode, EdgeMarker)>> {
2038 let Some(current) = resolve_exact_symbol(store, file, symbol, None)? else {
2039 return Ok(Vec::new());
2040 };
2041 let mut calls = Vec::new();
2042 for node in ¤t.nodes {
2043 calls.extend(store.outgoing_calls_of(node)?);
2044 }
2045 calls = dedup_call_sites(calls);
2046 calls.sort_by(|left, right| {
2047 left.byte_start
2048 .cmp(&right.byte_start)
2049 .then(left.line.cmp(&right.line))
2050 });
2051
2052 let mut callees = Vec::new();
2053 for site in calls {
2054 let resolved = resolve_exact_symbol(
2055 store,
2056 &site.target_file,
2057 &site.target_symbol,
2058 site.target.clone(),
2059 )?;
2060 if let Some(target) = resolved {
2061 callees.push((target.representative, edge_marker(&site)));
2062 }
2063 }
2064 Ok(callees)
2065}
2066
2067fn dedup_call_sites(sites: Vec<StoreCallSite>) -> Vec<StoreCallSite> {
2068 let mut seen = HashSet::new();
2069 let mut deduped = Vec::new();
2070 for site in sites {
2071 if seen.insert(call_site_key(&site)) {
2072 deduped.push(site);
2073 }
2074 }
2075 deduped
2076}
2077
2078#[cfg(test)]
2079fn dedup_call_site_count(sites: Vec<StoreCallSite>) -> usize {
2080 sites
2081 .into_iter()
2082 .map(|site| call_site_key(&site))
2083 .collect::<HashSet<_>>()
2084 .len()
2085}
2086
2087fn call_site_key(site: &StoreCallSite) -> (String, u32, String, String) {
2088 (
2089 site.caller.file.clone(),
2090 site.line,
2091 site.target_file.clone(),
2092 site.target_symbol.clone(),
2093 )
2094}
2095
2096fn trace_to_symbol_hop(node: &StoreNode) -> StoreTraceToSymbolHop {
2097 trace_to_symbol_hop_with_edge(node, EdgeMarker::default())
2098}
2099
2100fn trace_to_symbol_hop_with_edge(node: &StoreNode, edge: EdgeMarker) -> StoreTraceToSymbolHop {
2101 StoreTraceToSymbolHop {
2102 symbol: node.symbol.clone(),
2103 file: node.file.clone(),
2104 line: node.line,
2105 approximate: edge.approximate,
2106 resolved_by: edge.resolved_by,
2107 }
2108}
2109
2110fn trace_to_symbol_matches_target(
2111 file: &str,
2112 symbol: &str,
2113 to_symbol: &str,
2114 to_file: Option<&str>,
2115) -> bool {
2116 if !(symbol == to_symbol || unqualified_name(symbol) == to_symbol) {
2117 return false;
2118 }
2119 match to_file {
2120 Some(target_file) => file == target_file,
2121 None => true,
2122 }
2123}
2124
2125fn unqualified_name(symbol: &str) -> &str {
2126 symbol.rsplit("::").next().unwrap_or(symbol)
2127}
2128
2129fn read_source_line(path: &Path, line: u32) -> Option<String> {
2130 let source = std::fs::read_to_string(path).ok()?;
2131 source
2132 .lines()
2133 .nth(line.saturating_sub(1) as usize)
2134 .map(|line| line.trim().to_string())
2135}
2136
2137fn display_file_for_error(store: &impl CallGraphRead, file: &Path) -> String {
2138 absolute_file(store, file).display().to_string()
2139}
2140
2141fn relative_file(store: &impl CallGraphRead, file: &Path) -> String {
2142 let absolute = absolute_file(store, file);
2143 absolute
2144 .strip_prefix(store.project_root())
2145 .unwrap_or(&absolute)
2146 .to_string_lossy()
2147 .replace('\\', "/")
2148}
2149
2150fn absolute_file(store: &impl CallGraphRead, file: &Path) -> PathBuf {
2151 let full_path = if file.is_relative() {
2152 store.project_root().join(file)
2153 } else {
2154 file.to_path_buf()
2155 };
2156 std::fs::canonicalize(&full_path).unwrap_or(full_path)
2157}
2158
2159#[cfg(test)]
2160mod trace_to_tests {
2161 use super::*;
2162 use crate::callgraph_store::{
2163 Result as CallGraphResult, StoreCallersResult as RawCallersResult,
2164 StoreImpactResult as RawImpactResult, StoredEdge,
2165 };
2166 use std::cell::RefCell;
2167
2168 struct CountingStore {
2169 root: PathBuf,
2170 sqlite_path: PathBuf,
2171 nodes: HashMap<(String, String), StoreNode>,
2172 callers: HashMap<(String, String), Vec<StoreCallSite>>,
2173 outgoing: HashMap<(String, String), Vec<StoreCallSite>>,
2174 caller_queries: RefCell<HashMap<(String, String), usize>>,
2175 forward_query_count: RefCell<usize>,
2176 caller_count_queries: RefCell<usize>,
2177 caller_count_targets: RefCell<usize>,
2178 }
2179
2180 impl CountingStore {
2181 fn new() -> Self {
2182 Self {
2183 root: PathBuf::from("/repo"),
2184 sqlite_path: PathBuf::from("/repo/callgraph.sqlite"),
2185 nodes: HashMap::new(),
2186 callers: HashMap::new(),
2187 outgoing: HashMap::new(),
2188 caller_queries: RefCell::new(HashMap::new()),
2189 forward_query_count: RefCell::new(0),
2190 caller_count_queries: RefCell::new(0),
2191 caller_count_targets: RefCell::new(0),
2192 }
2193 }
2194
2195 fn add_node(&mut self, node: StoreNode) {
2196 self.nodes
2197 .insert((node.file.clone(), node.symbol.clone()), node);
2198 }
2199
2200 fn add_caller(&mut self, target: &StoreNode, caller: &StoreNode) {
2201 self.add_caller_at(target, caller, caller.line);
2202 }
2203
2204 fn add_caller_at(&mut self, target: &StoreNode, caller: &StoreNode, line: u32) {
2205 self.callers
2206 .entry((target.file.clone(), target.symbol.clone()))
2207 .or_default()
2208 .push(StoreCallSite {
2209 caller: caller.clone(),
2210 target_file: target.file.clone(),
2211 target_symbol: target.symbol.clone(),
2212 target: Some(target.clone()),
2213 line,
2214 byte_start: 0,
2215 byte_end: 1,
2216 resolved: true,
2217 provenance: TRACE_DATA_RESOLVER_PROVENANCE.to_string(),
2218 });
2219 }
2220
2221 fn add_outgoing(&mut self, caller: &StoreNode, target: &StoreNode) {
2222 self.outgoing
2223 .entry((caller.file.clone(), caller.symbol.clone()))
2224 .or_default()
2225 .push(StoreCallSite {
2226 caller: caller.clone(),
2227 target_file: target.file.clone(),
2228 target_symbol: target.symbol.clone(),
2229 target: Some(target.clone()),
2230 line: target.line,
2231 byte_start: 0,
2232 byte_end: 1,
2233 resolved: true,
2234 provenance: TRACE_DATA_RESOLVER_PROVENANCE.to_string(),
2235 });
2236 }
2237
2238 fn total_forward_queries(&self) -> usize {
2239 *self.forward_query_count.borrow()
2240 }
2241
2242 fn reset_forward_queries(&self) {
2243 *self.forward_query_count.borrow_mut() = 0;
2244 }
2245
2246 fn total_caller_queries(&self) -> usize {
2247 self.caller_queries.borrow().values().sum()
2248 }
2249
2250 fn total_caller_count_queries(&self) -> usize {
2251 *self.caller_count_queries.borrow()
2252 }
2253
2254 fn caller_count_target_count(&self) -> usize {
2255 *self.caller_count_targets.borrow()
2256 }
2257
2258 fn reset_query_counts(&self) {
2259 self.caller_queries.borrow_mut().clear();
2260 *self.caller_count_queries.borrow_mut() = 0;
2261 *self.caller_count_targets.borrow_mut() = 0;
2262 }
2263 }
2264
2265 impl CallGraphRead for CountingStore {
2266 fn project_root(&self) -> &Path {
2267 &self.root
2268 }
2269
2270 fn project_key(&self) -> &str {
2271 "test-project"
2272 }
2273
2274 fn sqlite_path(&self) -> &Path {
2275 &self.sqlite_path
2276 }
2277
2278 fn is_current(&self) -> bool {
2279 true
2280 }
2281
2282 fn edge_snapshot(&self) -> CallGraphResult<BTreeSet<StoredEdge>> {
2283 unreachable!("not used by trace_to_result")
2284 }
2285
2286 fn indexed_file_count(&self) -> CallGraphResult<usize> {
2287 Ok(self.nodes.len())
2288 }
2289
2290 fn node_for(&self, file_rel: &Path, symbol: &str) -> CallGraphResult<StoreNode> {
2291 Ok(self
2292 .nodes_for(file_rel, symbol)?
2293 .into_iter()
2294 .next()
2295 .expect("fixture node"))
2296 }
2297
2298 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> CallGraphResult<Vec<StoreNode>> {
2299 let key = (
2300 file_rel.to_string_lossy().replace('\\', "/"),
2301 symbol.to_string(),
2302 );
2303 Ok(self.nodes.get(&key).cloned().into_iter().collect())
2304 }
2305
2306 fn nodes_matching(&self, symbol: &str) -> CallGraphResult<Vec<StoreNode>> {
2307 Ok(self
2308 .nodes
2309 .values()
2310 .filter(|node| node.symbol == symbol)
2311 .cloned()
2312 .collect())
2313 }
2314
2315 fn direct_callers_of(
2316 &self,
2317 file_rel: &Path,
2318 symbol: &str,
2319 ) -> CallGraphResult<Vec<StoreCallSite>> {
2320 let key = (
2321 file_rel.to_string_lossy().replace('\\', "/"),
2322 symbol.to_string(),
2323 );
2324 *self
2325 .caller_queries
2326 .borrow_mut()
2327 .entry(key.clone())
2328 .or_default() += 1;
2329 Ok(self.callers.get(&key).cloned().unwrap_or_default())
2330 }
2331
2332 fn direct_caller_counts_of(
2333 &self,
2334 targets: &[(String, String)],
2335 ) -> CallGraphResult<HashMap<(String, String), usize>> {
2336 *self.caller_count_queries.borrow_mut() += 1;
2337 *self.caller_count_targets.borrow_mut() = targets.len();
2338 Ok(targets
2339 .iter()
2340 .cloned()
2341 .map(|target| {
2342 let count = self
2343 .callers
2344 .get(&target)
2345 .cloned()
2346 .map(dedup_call_site_count)
2347 .unwrap_or_default();
2348 (target, count)
2349 })
2350 .collect())
2351 }
2352
2353 fn callers_of(
2354 &self,
2355 _file_rel: &Path,
2356 _symbol: &str,
2357 _depth: usize,
2358 ) -> CallGraphResult<RawCallersResult> {
2359 unreachable!("not used by trace_to_result")
2360 }
2361
2362 fn impact_of(
2363 &self,
2364 _file_rel: &Path,
2365 _symbol: &str,
2366 _depth: usize,
2367 ) -> CallGraphResult<RawImpactResult> {
2368 unreachable!("not used by trace_to_result")
2369 }
2370
2371 fn outgoing_calls_of(&self, node: &StoreNode) -> CallGraphResult<Vec<StoreCallSite>> {
2372 *self.forward_query_count.borrow_mut() += 1;
2373 Ok(self
2374 .outgoing
2375 .get(&(node.file.clone(), node.symbol.clone()))
2376 .cloned()
2377 .unwrap_or_default())
2378 }
2379
2380 fn resolved_self_calls_of(&self, _node: &StoreNode) -> CallGraphResult<Vec<StoreCallSite>> {
2381 unreachable!("not used by these adapter tests")
2382 }
2383
2384 fn unresolved_calls_of(
2385 &self,
2386 _node: &StoreNode,
2387 ) -> CallGraphResult<Vec<StoreUnresolvedCall>> {
2388 *self.forward_query_count.borrow_mut() += 1;
2389 Ok(Vec::new())
2390 }
2391
2392 fn call_tree(
2393 &self,
2394 _file_rel: &Path,
2395 _symbol: &str,
2396 _depth: usize,
2397 ) -> CallGraphResult<callgraph::CallTreeNode> {
2398 unreachable!("not used by trace_to_result")
2399 }
2400
2401 fn trace_to(
2402 &self,
2403 _file_rel: &Path,
2404 _symbol: &str,
2405 _max_depth: usize,
2406 ) -> CallGraphResult<callgraph::TraceToResult> {
2407 unreachable!("not used by trace_to_result")
2408 }
2409
2410 fn trace_to_symbol_candidates(
2411 &self,
2412 _to_symbol: &str,
2413 ) -> CallGraphResult<Vec<TraceToSymbolCandidate>> {
2414 unreachable!("not used by trace_to_result")
2415 }
2416
2417 fn trace_to_symbol(
2418 &self,
2419 _file_rel: &Path,
2420 _symbol: &str,
2421 _to_symbol: &str,
2422 _to_file: Option<&Path>,
2423 _max_depth: usize,
2424 ) -> CallGraphResult<callgraph::TraceToSymbolResult> {
2425 unreachable!("not used by trace_to_result")
2426 }
2427 }
2428
2429 fn node(symbol: &str, is_entry_point: bool) -> StoreNode {
2430 StoreNode::for_test(&format!("{symbol}.ts"), symbol, is_entry_point)
2431 }
2432
2433 fn layered_store(width: usize, layers: usize) -> (CountingStore, StoreNode) {
2434 let mut store = CountingStore::new();
2435 let target = node("target", false);
2436 store.add_node(target.clone());
2437 let mut previous = vec![target.clone()];
2438 for layer in 1..=layers {
2439 let current = (0..width)
2440 .map(|index| node(&format!("layer_{layer}_{index}"), layer == layers))
2441 .collect::<Vec<_>>();
2442 for caller in ¤t {
2443 store.add_node(caller.clone());
2444 }
2445 for target_node in &previous {
2446 for caller in ¤t {
2447 store.add_caller(target_node, caller);
2448 }
2449 }
2450 previous = current;
2451 }
2452 (store, target)
2453 }
2454
2455 fn converging_call_tree_store(width: usize) -> (CountingStore, StoreNode) {
2456 let mut store = CountingStore::new();
2457 let root = node("root", false);
2458 let helper = node("helper", false);
2459 let leaf = node("leaf", false);
2460 for fixture_node in [&root, &helper, &leaf] {
2461 store.add_node(fixture_node.clone());
2462 }
2463 store.add_outgoing(&helper, &leaf);
2464
2465 for index in 0..width {
2466 let handler = node(&format!("handler_{index}"), false);
2467 store.add_node(handler.clone());
2468 store.add_outgoing(&root, &handler);
2469 store.add_outgoing(&handler, &helper);
2470 }
2471 (store, root)
2472 }
2473
2474 fn call_tree_node_count(tree: &StoreCallTreeNode) -> usize {
2475 1 + tree
2476 .children
2477 .iter()
2478 .map(call_tree_node_count)
2479 .sum::<usize>()
2480 }
2481
2482 #[test]
2483 fn call_tree_memoizes_only_adjacency_and_preserves_rendered_tree() {
2484 let (store, root) = converging_call_tree_store(200);
2485
2486 let memoized = call_tree_result(&store, Path::new(&root.file), &root.symbol, 3, true)
2487 .expect("memoized call tree");
2488 let memoized_queries = store.total_forward_queries();
2489
2490 store.reset_forward_queries();
2491 let resolved_root = ResolvedStoreSymbol {
2492 representative: root.clone(),
2493 nodes: vec![root],
2494 };
2495 let mut visited = HashSet::new();
2496 let mut unused_cache = HashMap::new();
2497 let uncached = call_tree_inner(
2498 &store,
2499 &resolved_root,
2500 3,
2501 0,
2502 &mut visited,
2503 &mut unused_cache,
2504 false,
2505 )
2506 .expect("uncached call tree");
2507 let uncached_queries = store.total_forward_queries();
2508
2509 assert_eq!(call_tree_node_count(&memoized), 601);
2510 assert_eq!(
2511 serde_json::to_vec(&memoized).expect("serialize memoized tree"),
2512 serde_json::to_vec(&uncached).expect("serialize uncached tree"),
2513 "adjacency memoization must not change rendered call-tree bytes"
2514 );
2515 assert_eq!(uncached_queries, 1_202);
2516 assert_eq!(memoized_queries, 406);
2517 }
2518
2519 #[test]
2520 fn batched_boundary_counts_preserve_serialized_callers_and_impact_contract() {
2521 let mut store = CountingStore::new();
2522 let target = node("target", false);
2523 let boundary = node("hubCaller", false);
2524 let upstream_a = node("upstreamA", true);
2525 let upstream_b = node("upstreamB", true);
2526 for fixture_node in [&target, &boundary, &upstream_a, &upstream_b] {
2527 store.add_node(fixture_node.clone());
2528 }
2529 for line in 1..=21 {
2530 store.add_caller_at(&target, &boundary, line);
2531 }
2532 store.add_caller(&boundary, &upstream_a);
2533 store.add_caller(&boundary, &upstream_b);
2534
2535 let callers = callers_result(&store, Path::new(&target.file), &target.symbol, 1, true)
2536 .expect("callers result");
2537 assert_eq!(store.total_caller_queries(), 1);
2538 assert_eq!(store.total_caller_count_queries(), 1);
2539 assert_eq!(store.caller_count_target_count(), 1);
2540 assert_eq!(
2541 serde_json::to_string(&callers).expect("serialize callers result"),
2542 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}"#
2543 );
2544
2545 store.reset_query_counts();
2546 let impact = impact_result(&store, Path::new(&target.file), &target.symbol, 1, true)
2547 .expect("impact result");
2548 assert_eq!(store.total_caller_queries(), 1);
2549 assert_eq!(store.total_caller_count_queries(), 1);
2550 assert_eq!(store.caller_count_target_count(), 1);
2551 assert_eq!(
2552 serde_json::to_string(&impact).expect("serialize impact result"),
2553 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}"#
2554 );
2555 }
2556
2557 #[test]
2558 fn trace_to_caches_callers_for_convergent_path_prefixes() {
2559 let (store, target) = layered_store(2, 3);
2560
2561 let (result, expansions) = trace_to_result_with_budget(
2562 &store,
2563 Path::new(&target.file),
2564 &target.symbol,
2565 10,
2566 true,
2567 100,
2568 )
2569 .expect("trace result");
2570
2571 assert_eq!(result.total_paths, 8);
2572 assert!(!result.total_paths_is_lower_bound);
2573 assert_eq!(expansions, 15);
2574 assert_eq!(store.total_caller_queries(), 7);
2575 assert!(store
2576 .caller_queries
2577 .borrow()
2578 .values()
2579 .all(|queries| *queries == 1));
2580 }
2581
2582 #[test]
2583 fn trace_to_budget_returns_valid_paths_and_marks_counts_as_lower_bounds() {
2584 let (store, target) = layered_store(2, 3);
2585
2586 let (result, expansions) = trace_to_result_with_budget(
2587 &store,
2588 Path::new(&target.file),
2589 &target.symbol,
2590 10,
2591 true,
2592 10,
2593 )
2594 .expect("trace result");
2595
2596 assert!(result.total_paths_is_lower_bound);
2597 assert!(result.total_paths > 0);
2598 assert!(expansions <= 10);
2599 assert!(store.total_caller_queries() <= 10);
2600 let summary = result.hub_summary.expect("lower-bound summary");
2601 assert!(summary.counts_are_lower_bounds);
2602 assert!(summary.message.contains("at least"));
2603 for path in result.paths {
2604 assert!(path.hops.first().is_some_and(|hop| hop.is_entry_point));
2605 assert_eq!(
2606 path.hops.last().map(|hop| hop.symbol.as_str()),
2607 Some("target")
2608 );
2609 }
2610 }
2611
2612 #[test]
2613 fn trace_to_below_budget_preserves_exact_serialized_contract() {
2614 let mut store = CountingStore::new();
2615 let target = node("target", false);
2616 let middle = node("middle", false);
2617 let entry = node("entry", true);
2618 for fixture_node in [&target, &middle, &entry] {
2619 store.add_node(fixture_node.clone());
2620 }
2621 store.add_caller(&target, &middle);
2622 store.add_caller(&middle, &entry);
2623
2624 let (result, _expansions) = trace_to_result_with_budget(
2625 &store,
2626 Path::new(&target.file),
2627 &target.symbol,
2628 10,
2629 true,
2630 100,
2631 )
2632 .expect("trace result");
2633
2634 assert_eq!(
2635 serde_json::to_string(&result).expect("serialize trace result"),
2636 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}"#
2637 );
2638 }
2639}