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 !queue.is_empty() {
907 let frontier_len = queue.len();
908 let frontier = queue
909 .iter()
910 .take(frontier_len)
911 .map(|(file, symbol, _, _)| (file.clone(), symbol.clone()))
912 .collect::<Vec<_>>();
913 let mut calls_by_symbol = store.outgoing_calls_for_symbols(&frontier)?;
914
915 for _ in 0..frontier_len {
918 let Some((current_file, current_symbol, path, depth)) = queue.pop_front() else {
919 break;
920 };
921 let calls = calls_by_symbol
922 .remove(&(current_file, current_symbol))
923 .unwrap_or_default();
924 let callees = forward_resolved_callees(calls);
925
926 if depth >= effective_max {
927 if callees
928 .iter()
929 .any(|(node, _)| !visited.contains(&(node.file.clone(), node.symbol.clone())))
930 {
931 max_depth_exhausted = true;
932 }
933 continue;
934 }
935
936 for (callee, edge) in callees {
937 if !include_tests && is_test_file(&callee.file) {
938 continue;
939 }
940 if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
941 continue;
942 }
943 let mut next_path = path.clone();
944 next_path.push(trace_to_symbol_hop_with_edge(&callee, edge));
945 if trace_to_symbol_matches_target(
946 &callee.file,
947 &callee.symbol,
948 to_symbol,
949 target_file.as_deref(),
950 ) {
951 return Ok(StoreTraceToSymbolResult {
952 path: Some(next_path),
953 complete: true,
954 reason: None,
955 });
956 }
957 queue.push_back((callee.file, callee.symbol, next_path, depth + 1));
958 }
959 }
960 }
961
962 if max_depth_exhausted {
963 Ok(StoreTraceToSymbolResult {
964 path: None,
965 complete: false,
966 reason: Some("max_depth_exhausted".to_string()),
967 })
968 } else {
969 Ok(StoreTraceToSymbolResult {
970 path: None,
971 complete: true,
972 reason: Some("no_path_found".to_string()),
973 })
974 }
975}
976
977#[derive(Debug, Default)]
978struct TrackedBindings {
979 approximate_by_name: HashMap<String, bool>,
980}
981
982struct AssignmentInfo {
983 binding: String,
984 hop_variable: String,
985 line: u32,
986 approximate: bool,
987 stop_after_hop: bool,
988}
989
990impl TrackedBindings {
991 fn with_origin(name: &str, approximate: bool) -> Self {
992 let mut bindings = Self::default();
993 bindings.track(name.to_string(), approximate);
994 bindings
995 }
996
997 fn contains(&self, name: &str) -> bool {
998 self.approximate_by_name.contains_key(name)
999 }
1000
1001 fn approximation(&self, name: &str) -> Option<bool> {
1002 self.approximate_by_name.get(name).copied()
1003 }
1004
1005 fn track(&mut self, name: String, approximate: bool) {
1006 self.approximate_by_name.insert(name, approximate);
1007 }
1008
1009 fn kill(&mut self, name: &str) {
1010 self.approximate_by_name.remove(name);
1011 }
1012
1013 fn mark_approximate(&mut self, name: &str) {
1014 if let Some(approximate) = self.approximate_by_name.get_mut(name) {
1015 *approximate = true;
1016 }
1017 }
1018}
1019
1020pub fn trace_data_result(
1021 store: &impl CallGraphRead,
1022 file: &Path,
1023 symbol: &str,
1024 expression: &str,
1025 max_depth: usize,
1026 symbol_cache: SharedSymbolCache,
1027) -> StoreAdapterResult<callgraph::TraceDataResult> {
1028 let origin_path = absolute_file(store, file);
1029 let origin_file = relative_file(store, &origin_path);
1030 let origin_symbol = resolve_symbol_query_with_cache(&origin_path, symbol, &symbol_cache)?;
1031
1032 let mut hops = Vec::new();
1033 let mut depth_limited = false;
1034 let mut visited = HashSet::new();
1035 trace_data_inner(
1036 store,
1037 &symbol_cache,
1038 &origin_path,
1039 &origin_symbol,
1040 expression,
1041 false,
1042 max_depth,
1043 0,
1044 &mut hops,
1045 &mut depth_limited,
1046 &mut visited,
1047 )?;
1048
1049 Ok(callgraph::TraceDataResult {
1050 expression: expression.to_string(),
1051 origin_file,
1052 origin_symbol,
1053 hops,
1054 depth_limited,
1055 })
1056}
1057
1058#[allow(clippy::too_many_arguments)]
1059fn trace_data_inner(
1060 store: &impl CallGraphRead,
1061 symbol_cache: &SharedSymbolCache,
1062 file: &Path,
1063 symbol: &str,
1064 tracking_name: &str,
1065 tracking_approximate: bool,
1066 max_depth: usize,
1067 current_depth: usize,
1068 hops: &mut Vec<callgraph::DataFlowHop>,
1069 depth_limited: &mut bool,
1070 visited: &mut HashSet<(String, String, String, bool)>,
1071) -> StoreAdapterResult<()> {
1072 let rel_file = relative_file(store, file);
1073 let visit_key = (
1074 rel_file.clone(),
1075 symbol.to_string(),
1076 tracking_name.to_string(),
1077 tracking_approximate,
1078 );
1079 if visited.contains(&visit_key) {
1080 return Ok(());
1081 }
1082 visited.insert(visit_key);
1083
1084 let current = resolve_exact_symbol(store, &rel_file, symbol, None)?
1085 .ok_or_else(|| CallGraphStoreError::StaleFiles(vec![rel_file.clone()]))?;
1086 let current_calls = trace_forward_calls_for_nodes(store, ¤t.nodes)?;
1087
1088 let source = std::fs::read_to_string(file)?;
1091 let Some(lang) = detect_language(file) else {
1092 return Ok(());
1093 };
1094 let grammar = grammar_for(lang);
1095 let mut parser = Parser::new();
1096 parser
1097 .set_language(&grammar)
1098 .map_err(|error| AftError::ParseError {
1099 message: format!("grammar init failed for {:?}: {}", lang, error),
1100 })?;
1101 let tree = parser
1102 .parse(&source, None)
1103 .ok_or_else(|| AftError::ParseError {
1104 message: format!("parse failed for {}", file.display()),
1105 })?;
1106 let symbols = extract_symbols_from_tree(&source, &tree, lang)?;
1107 let sym_info = symbols
1108 .iter()
1109 .find(|candidate| {
1110 symbol_identity_from_cache(candidate) == symbol || candidate.name == symbol
1111 })
1112 .ok_or_else(|| CallGraphStoreError::StaleFiles(vec![rel_file.clone()]))?;
1113
1114 let body_start = line_col_to_byte(&source, sym_info.range.start_line, sym_info.range.start_col);
1115 let body_end = line_col_to_byte(&source, sym_info.range.end_line, sym_info.range.end_col);
1116 let Some(body_node) = find_node_covering_range(tree.root_node(), body_start, body_end) else {
1117 return Ok(());
1118 };
1119
1120 let mut tracked = TrackedBindings::with_origin(tracking_name, tracking_approximate);
1121 walk_for_data_flow(
1122 store,
1123 symbol_cache,
1124 body_node,
1125 &source,
1126 ¤t_calls,
1127 &mut tracked,
1128 symbol,
1129 &rel_file,
1130 max_depth,
1131 current_depth,
1132 false,
1133 false,
1134 hops,
1135 depth_limited,
1136 visited,
1137 )
1138}
1139
1140#[allow(clippy::too_many_arguments)]
1141fn walk_for_data_flow(
1142 store: &impl CallGraphRead,
1143 symbol_cache: &SharedSymbolCache,
1144 node: Node<'_>,
1145 source: &str,
1146 current_calls: &[TraceForwardCall],
1147 tracked: &mut TrackedBindings,
1148 symbol: &str,
1149 rel_file: &str,
1150 max_depth: usize,
1151 current_depth: usize,
1152 control_flow_uncertain: bool,
1153 origin_function_seen: bool,
1154 hops: &mut Vec<callgraph::DataFlowHop>,
1155 depth_limited: &mut bool,
1156 visited: &mut HashSet<(String, String, String, bool)>,
1157) -> StoreAdapterResult<()> {
1158 let kind = node.kind();
1159 let is_var_decl = matches!(
1160 kind,
1161 "variable_declarator"
1162 | "assignment_expression"
1163 | "augmented_assignment_expression"
1164 | "assignment"
1165 | "let_declaration"
1166 | "short_var_declaration"
1167 );
1168
1169 if is_var_decl {
1170 if let Some(assignment) = extract_assignment_info(node, source, tracked) {
1171 let approximate = assignment.approximate || control_flow_uncertain;
1172 hops.push(callgraph::DataFlowHop {
1173 file: rel_file.to_string(),
1174 symbol: symbol.to_string(),
1175 variable: assignment.hop_variable,
1176 line: assignment.line,
1177 flow_type: "assignment".to_string(),
1178 approximate,
1179 });
1180 tracked.track(assignment.binding, approximate);
1181 if assignment.stop_after_hop {
1182 return Ok(());
1183 }
1184 } else if let Some(overwritten_name) = plain_assignment_target(node, source) {
1185 if tracked.contains(&overwritten_name) {
1186 if control_flow_uncertain {
1187 tracked.mark_approximate(&overwritten_name);
1190 } else {
1191 tracked.kill(&overwritten_name);
1192 }
1193 }
1194 }
1195 }
1196
1197 if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
1198 check_call_for_data_flow(
1199 store,
1200 symbol_cache,
1201 node,
1202 source,
1203 current_calls,
1204 tracked,
1205 symbol,
1206 rel_file,
1207 max_depth,
1208 current_depth,
1209 hops,
1210 depth_limited,
1211 visited,
1212 )?;
1213 }
1214
1215 let is_function = function_container(kind);
1216 let descendants_uncertain = control_flow_uncertain
1217 || conditional_flow_container(kind)
1218 || (is_function && origin_function_seen);
1219 let descendants_origin_function_seen = origin_function_seen || is_function;
1220 let mut cursor = node.walk();
1221 if cursor.goto_first_child() {
1222 loop {
1223 walk_for_data_flow(
1224 store,
1225 symbol_cache,
1226 cursor.node(),
1227 source,
1228 current_calls,
1229 tracked,
1230 symbol,
1231 rel_file,
1232 max_depth,
1233 current_depth,
1234 descendants_uncertain,
1235 descendants_origin_function_seen,
1236 hops,
1237 depth_limited,
1238 visited,
1239 )?;
1240 if !cursor.goto_next_sibling() {
1241 break;
1242 }
1243 }
1244 }
1245 Ok(())
1246}
1247
1248fn extract_assignment_info(
1249 node: Node<'_>,
1250 source: &str,
1251 tracked: &TrackedBindings,
1252) -> Option<AssignmentInfo> {
1253 let kind = node.kind();
1254 let line = node.start_position().row as u32 + 1;
1255
1256 let (name_node, value_node) = match kind {
1257 "variable_declarator" => (
1258 node.child_by_field_name("name")?,
1259 node.child_by_field_name("value")?,
1260 ),
1261 "assignment_expression" | "augmented_assignment_expression" | "assignment" => (
1262 node.child_by_field_name("left")?,
1263 node.child_by_field_name("right")?,
1264 ),
1265 "let_declaration" | "short_var_declaration" => (
1266 node.child_by_field_name("pattern")
1267 .or_else(|| node.child_by_field_name("left"))?,
1268 node.child_by_field_name("value")
1269 .or_else(|| node.child_by_field_name("right"))?,
1270 ),
1271 _ => return None,
1272 };
1273
1274 let binding = trace_node_text(name_node, source);
1275 if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
1276 tracked_reference_approximation(value_node, source, tracked)?;
1277 return Some(AssignmentInfo {
1278 binding: binding.clone(),
1279 hop_variable: binding,
1280 line,
1281 approximate: true,
1282 stop_after_hop: true,
1283 });
1284 }
1285
1286 let source_approximate = if kind == "augmented_assignment_expression" {
1287 merge_reference_approximation(
1288 tracked_reference_approximation(name_node, source, tracked),
1289 tracked_reference_approximation(value_node, source, tracked),
1290 )?
1291 } else {
1292 tracked_reference_approximation(value_node, source, tracked)?
1293 };
1294
1295 Some(AssignmentInfo {
1296 binding: binding.clone(),
1297 hop_variable: binding,
1298 line,
1299 approximate: source_approximate,
1300 stop_after_hop: false,
1301 })
1302}
1303
1304fn merge_reference_approximation(left: Option<bool>, right: Option<bool>) -> Option<bool> {
1305 match (left, right) {
1306 (Some(left), Some(right)) => Some(left && right),
1307 (Some(approximate), None) | (None, Some(approximate)) => Some(approximate),
1308 (None, None) => None,
1309 }
1310}
1311
1312fn tracked_reference_approximation(
1313 node: Node<'_>,
1314 source: &str,
1315 tracked: &TrackedBindings,
1316) -> Option<bool> {
1317 let mut approximation = if is_identifier_reference(node) {
1318 tracked.approximation(&trace_node_text(node, source))
1319 } else {
1320 None
1321 };
1322
1323 let mut cursor = node.walk();
1324 if cursor.goto_first_child() {
1325 loop {
1326 approximation = merge_reference_approximation(
1327 approximation,
1328 tracked_reference_approximation(cursor.node(), source, tracked),
1329 );
1330 if !cursor.goto_next_sibling() {
1331 break;
1332 }
1333 }
1334 }
1335 approximation
1336}
1337
1338fn is_identifier_reference(node: Node<'_>) -> bool {
1339 if !matches!(
1340 node.kind(),
1341 "identifier" | "simple_identifier" | "variable_name" | "shorthand_property_identifier"
1342 ) {
1343 return false;
1344 }
1345
1346 let Some(parent) = node.parent() else {
1347 return true;
1348 };
1349 !["property", "attribute", "field"].iter().any(|field| {
1350 parent
1351 .child_by_field_name(field)
1352 .is_some_and(|child| child.id() == node.id())
1353 })
1354}
1355
1356fn plain_assignment_target(node: Node<'_>, source: &str) -> Option<String> {
1357 if node.kind() == "augmented_assignment_expression" {
1358 return None;
1359 }
1360 let target = match node.kind() {
1361 "assignment_expression" | "assignment" => node.child_by_field_name("left")?,
1362 _ => return None,
1363 };
1364 is_identifier_reference(target).then(|| trace_node_text(target, source))
1365}
1366
1367fn conditional_flow_container(kind: &str) -> bool {
1368 matches!(
1369 kind,
1370 "if_statement"
1371 | "else_clause"
1372 | "conditional_expression"
1373 | "ternary_expression"
1374 | "switch_statement"
1375 | "switch_expression"
1376 | "match_expression"
1377 | "when_expression"
1378 | "for_statement"
1379 | "for_in_statement"
1380 | "for_each_statement"
1381 | "while_statement"
1382 | "do_statement"
1383 | "try_statement"
1384 | "catch_clause"
1385 | "finally_clause"
1386 )
1387}
1388
1389fn function_container(kind: &str) -> bool {
1390 matches!(
1391 kind,
1392 "function_declaration"
1393 | "function_expression"
1394 | "arrow_function"
1395 | "method_definition"
1396 | "lambda"
1397 | "lambda_expression"
1398 | "closure_expression"
1399 )
1400}
1401
1402#[allow(clippy::too_many_arguments)]
1403fn check_call_for_data_flow(
1404 store: &impl CallGraphRead,
1405 symbol_cache: &SharedSymbolCache,
1406 node: Node<'_>,
1407 source: &str,
1408 current_calls: &[TraceForwardCall],
1409 tracked: &TrackedBindings,
1410 symbol: &str,
1411 rel_file: &str,
1412 max_depth: usize,
1413 current_depth: usize,
1414 hops: &mut Vec<callgraph::DataFlowHop>,
1415 depth_limited: &mut bool,
1416 visited: &mut HashSet<(String, String, String, bool)>,
1417) -> StoreAdapterResult<()> {
1418 let args_node =
1419 find_child_by_kind(node, "arguments").or_else(|| find_child_by_kind(node, "argument_list"));
1420 let Some(args_node) = args_node else {
1421 return Ok(());
1422 };
1423
1424 let mut arg_positions = Vec::new();
1425 let mut arg_idx = 0usize;
1426 let mut cursor = args_node.walk();
1427 if cursor.goto_first_child() {
1428 loop {
1429 let child = cursor.node();
1430 let child_kind = child.kind();
1431 if child_kind == "(" || child_kind == ")" || child_kind == "," {
1432 if !cursor.goto_next_sibling() {
1433 break;
1434 }
1435 continue;
1436 }
1437
1438 let arg_text = trace_node_text(child, source);
1439 if child_kind == "spread_element" || child_kind == "dictionary_splat" {
1440 if tracked_reference_approximation(child, source, tracked).is_some() {
1441 hops.push(callgraph::DataFlowHop {
1442 file: rel_file.to_string(),
1443 symbol: symbol.to_string(),
1444 variable: arg_text,
1445 line: child.start_position().row as u32 + 1,
1446 flow_type: "parameter".to_string(),
1447 approximate: true,
1448 });
1449 }
1450 if !cursor.goto_next_sibling() {
1451 break;
1452 }
1453 arg_idx += 1;
1454 continue;
1455 }
1456
1457 if let Some(approximate) = tracked.approximation(&arg_text) {
1458 arg_positions.push((arg_idx, arg_text, approximate));
1459 }
1460
1461 arg_idx += 1;
1462 if !cursor.goto_next_sibling() {
1463 break;
1464 }
1465 }
1466 }
1467
1468 if arg_positions.is_empty() {
1469 return Ok(());
1470 }
1471
1472 let matched_call = current_calls
1473 .iter()
1474 .find(|call| call.matches_position(node.start_byte(), node.end_byte()));
1475
1476 match matched_call {
1477 Some(TraceForwardCall::Resolved(site)) => {
1478 let Some(target) = trace_target_node(store, site)? else {
1479 return Ok(());
1480 };
1481 if target.file != rel_file && current_depth + 1 > max_depth {
1482 *depth_limited = true;
1483 return Ok(());
1484 }
1485 let params = target
1486 .signature
1487 .as_deref()
1488 .map(|signature| callgraph::extract_parameters(signature, target.lang))
1489 .unwrap_or_default();
1490 let target_file = store.project_root().join(&target.file);
1491 for (pos, _tracked, approximate) in &arg_positions {
1492 if let Some(param_name) = params.get(*pos) {
1493 hops.push(callgraph::DataFlowHop {
1494 file: target.file.clone(),
1495 symbol: target.symbol.clone(),
1496 variable: param_name.clone(),
1497 line: target.line,
1498 flow_type: "parameter".to_string(),
1499 approximate: *approximate,
1500 });
1501 trace_data_inner(
1502 store,
1503 symbol_cache,
1504 &target_file,
1505 &target.symbol,
1506 param_name,
1507 *approximate,
1508 max_depth,
1509 current_depth + 1,
1510 hops,
1511 depth_limited,
1512 visited,
1513 )?;
1514 }
1515 }
1516 }
1517 Some(TraceForwardCall::Unresolved(call)) => {
1518 push_unresolved_parameter_hops(hops, rel_file, &call.symbol, &arg_positions, node);
1519 }
1520 None => {
1521 let (_full_callee, short_callee) = extract_callee_names(node, source);
1522 if let Some(callee_name) = short_callee {
1523 push_unresolved_parameter_hops(hops, rel_file, &callee_name, &arg_positions, node);
1524 }
1525 }
1526 }
1527
1528 Ok(())
1529}
1530
1531fn push_unresolved_parameter_hops(
1532 hops: &mut Vec<callgraph::DataFlowHop>,
1533 rel_file: &str,
1534 callee_name: &str,
1535 arg_positions: &[(usize, String, bool)],
1536 call_node: Node<'_>,
1537) {
1538 for (_pos, tracked, _approximate) in arg_positions {
1539 hops.push(callgraph::DataFlowHop {
1540 file: rel_file.to_string(),
1541 symbol: callee_name.to_string(),
1542 variable: tracked.clone(),
1543 line: call_node.start_position().row as u32 + 1,
1544 flow_type: "parameter".to_string(),
1545 approximate: true,
1546 });
1547 }
1548}
1549
1550fn trace_target_node(
1551 store: &impl CallGraphRead,
1552 site: &StoreCallSite,
1553) -> StoreAdapterResult<Option<StoreNode>> {
1554 if let Some(target) = &site.target {
1555 return Ok(Some(target.clone()));
1556 }
1557 resolve_exact_symbol(store, &site.target_file, &site.target_symbol, None)
1558 .map(|resolved| resolved.map(|symbol| symbol.representative))
1559}
1560
1561fn trace_forward_calls_for_nodes(
1562 store: &impl CallGraphRead,
1563 nodes: &[StoreNode],
1564) -> StoreAdapterResult<Vec<TraceForwardCall>> {
1565 let mut calls = Vec::new();
1566 for node in nodes {
1567 calls.extend(
1568 store
1569 .outgoing_calls_of(node)?
1570 .into_iter()
1571 .filter(|site| site.resolved_by() == TRACE_DATA_RESOLVER_PROVENANCE)
1572 .map(TraceForwardCall::Resolved),
1573 );
1574 calls.extend(
1575 store
1576 .resolved_self_calls_of(node)?
1577 .into_iter()
1578 .filter(|site| site.resolved_by() == TRACE_DATA_RESOLVER_PROVENANCE)
1579 .map(TraceForwardCall::Resolved),
1580 );
1581 calls.extend(
1582 store
1583 .unresolved_calls_of(node)?
1584 .into_iter()
1585 .map(TraceForwardCall::Unresolved),
1586 );
1587 }
1588 calls.sort_by(|left, right| {
1589 left.byte_start()
1590 .cmp(&right.byte_start())
1591 .then(left.byte_end().cmp(&right.byte_end()))
1592 .then(left.line().cmp(&right.line()))
1593 });
1594 Ok(calls)
1595}
1596
1597fn resolve_symbol_query_with_cache(
1598 file: &Path,
1599 symbol: &str,
1600 symbol_cache: &SharedSymbolCache,
1601) -> StoreAdapterResult<String> {
1602 let mut parser = FileParser::with_symbol_cache(symbol_cache.clone());
1603 let symbols = parser.extract_symbols(file)?;
1604 let candidates = symbol_query_candidates_from_symbols(&symbols, symbol);
1605 match candidates.as_slice() {
1606 [candidate] => Ok(candidate.clone()),
1607 [] => Err(AftError::SymbolNotFound {
1608 name: symbol.to_string(),
1609 file: file.display().to_string(),
1610 }
1611 .into()),
1612 _ => Err(AftError::AmbiguousSymbol {
1613 name: symbol.to_string(),
1614 candidates,
1615 }
1616 .into()),
1617 }
1618}
1619
1620fn symbol_query_candidates_from_symbols(symbols: &[Symbol], symbol_name: &str) -> Vec<String> {
1621 let mut seen = HashSet::new();
1622 let mut candidates = Vec::new();
1623 let qualified_query = symbol_name.contains("::");
1624
1625 let mut consider = |candidate: String| {
1626 let matches = if qualified_query {
1627 candidate == symbol_name
1628 } else {
1629 candidate == symbol_name || unqualified_name(&candidate) == symbol_name
1630 };
1631 if matches && seen.insert(candidate.clone()) {
1632 candidates.push(candidate);
1633 }
1634 };
1635
1636 for symbol in symbols {
1637 consider(symbol_identity_from_cache(symbol));
1638 if symbol.exported {
1639 consider(symbol.name.clone());
1640 }
1641 }
1642
1643 candidates.sort();
1644 candidates
1645}
1646
1647fn symbol_identity_from_cache(symbol: &Symbol) -> String {
1648 if symbol.scope_chain.is_empty() {
1649 symbol.name.clone()
1650 } else {
1651 format!("{}::{}", symbol.scope_chain.join("::"), symbol.name)
1652 }
1653}
1654
1655fn trace_node_text(node: Node<'_>, source: &str) -> String {
1656 source[node.start_byte()..node.end_byte()].to_string()
1657}
1658
1659fn find_node_covering_range(root: Node<'_>, start: usize, end: usize) -> Option<Node<'_>> {
1660 let mut best = None;
1661 let mut cursor = root.walk();
1662
1663 fn walk_covering<'a>(
1664 cursor: &mut tree_sitter::TreeCursor<'a>,
1665 start: usize,
1666 end: usize,
1667 best: &mut Option<Node<'a>>,
1668 ) {
1669 let node = cursor.node();
1670 if node.start_byte() <= start && node.end_byte() >= end {
1671 *best = Some(node);
1672 if cursor.goto_first_child() {
1673 loop {
1674 walk_covering(cursor, start, end, best);
1675 if !cursor.goto_next_sibling() {
1676 break;
1677 }
1678 }
1679 cursor.goto_parent();
1680 }
1681 }
1682 }
1683
1684 walk_covering(&mut cursor, start, end, &mut best);
1685 best
1686}
1687
1688fn find_child_by_kind<'tree>(node: Node<'tree>, kind: &str) -> Option<Node<'tree>> {
1689 let mut cursor = node.walk();
1690 if cursor.goto_first_child() {
1691 loop {
1692 if cursor.node().kind() == kind {
1693 return Some(cursor.node());
1694 }
1695 if !cursor.goto_next_sibling() {
1696 break;
1697 }
1698 }
1699 }
1700 None
1701}
1702
1703fn extract_callee_names(node: Node<'_>, source: &str) -> (Option<String>, Option<String>) {
1704 let Some(callee) = node.child_by_field_name("function") else {
1705 return (None, None);
1706 };
1707 let full = trace_node_text(callee, source);
1708 let short = if full.contains('.') {
1709 full.rsplit('.').next().unwrap_or(&full).to_string()
1710 } else {
1711 full.clone()
1712 };
1713 (Some(full), Some(short))
1714}
1715
1716pub fn store_error_response(req_id: &str, operation: &str, error: CallGraphStoreError) -> Response {
1717 match error {
1718 CallGraphStoreError::Aft(error) => Response::error(req_id, error.code(), error.to_string()),
1719 CallGraphStoreError::Unavailable(message) => Response::error(
1720 req_id,
1721 "callgraph_unavailable",
1722 format!("{operation}: persisted callgraph store unavailable: {message}"),
1723 ),
1724 CallGraphStoreError::StaleFiles(files) => Response::error(
1725 req_id,
1726 "callgraph_stale",
1727 format!(
1728 "{operation}: persisted callgraph store has stale files: {}",
1729 files.join(", ")
1730 ),
1731 ),
1732 other => Response::error(
1733 req_id,
1734 "callgraph_store_error",
1735 format!("{operation}: persisted callgraph store error: {other}"),
1736 ),
1737 }
1738}
1739
1740pub fn building_response(req_id: &str, operation: &str) -> Response {
1744 Response::error(
1745 req_id,
1746 "callgraph_building",
1747 format!("{operation}: callgraph store is building in the background; retry shortly"),
1748 )
1749}
1750
1751pub fn unavailable_response(req_id: &str, operation: &str, worktree: bool) -> Response {
1752 let message = if worktree {
1753 format!(
1754 "{operation}: persisted callgraph store is unavailable in this read-only worktree; run a callgraph operation in the main checkout to build it first"
1755 )
1756 } else {
1757 format!("{operation}: project not configured — send 'configure' first")
1758 };
1759 let code = if worktree {
1760 "callgraph_unavailable"
1761 } else {
1762 "not_configured"
1763 };
1764 Response::error(req_id, code, message)
1765}
1766
1767fn resolve_symbol_query(
1768 store: &impl CallGraphRead,
1769 file: &Path,
1770 symbol: &str,
1771) -> StoreAdapterResult<ResolvedStoreSymbol> {
1772 let nodes = store.nodes_for(file, symbol)?;
1773 collapse_symbol_nodes(store, file, symbol, nodes)
1774}
1775
1776fn resolve_exact_symbol(
1777 store: &impl CallGraphRead,
1778 file: &str,
1779 symbol: &str,
1780 fallback: Option<StoreNode>,
1781) -> StoreAdapterResult<Option<ResolvedStoreSymbol>> {
1782 let nodes = store
1783 .nodes_for(Path::new(file), symbol)?
1784 .into_iter()
1785 .filter(|node| node.symbol == symbol)
1786 .collect::<Vec<_>>();
1787 if nodes.is_empty() {
1788 return Ok(fallback.map(|node| ResolvedStoreSymbol {
1789 representative: node.clone(),
1790 nodes: vec![node],
1791 }));
1792 }
1793 Ok(Some(collapse_exact_nodes(nodes)))
1794}
1795
1796fn collapse_symbol_nodes(
1797 store: &impl CallGraphRead,
1798 file: &Path,
1799 query: &str,
1800 nodes: Vec<StoreNode>,
1801) -> StoreAdapterResult<ResolvedStoreSymbol> {
1802 let mut by_symbol: BTreeMap<String, Vec<StoreNode>> = BTreeMap::new();
1803 for node in nodes {
1804 by_symbol.entry(node.symbol.clone()).or_default().push(node);
1805 }
1806
1807 match by_symbol.len() {
1808 0 => Err(CallGraphStoreError::Aft(AftError::SymbolNotFound {
1809 name: query.to_string(),
1810 file: display_file_for_error(store, file),
1811 })),
1812 1 => Ok(collapse_exact_nodes(
1813 by_symbol.into_values().next().unwrap_or_default(),
1814 )),
1815 _ => Err(CallGraphStoreError::Aft(AftError::AmbiguousSymbol {
1816 name: query.to_string(),
1817 candidates: by_symbol.into_keys().collect(),
1818 })),
1819 }
1820}
1821
1822fn collapse_exact_nodes(mut nodes: Vec<StoreNode>) -> ResolvedStoreSymbol {
1823 nodes.sort_by(|left, right| {
1824 left.symbol
1825 .cmp(&right.symbol)
1826 .then(left.line.cmp(&right.line))
1827 .then(left.end_line.cmp(&right.end_line))
1828 });
1829 let representative = nodes[0].clone();
1830 ResolvedStoreSymbol {
1831 representative,
1832 nodes,
1833 }
1834}
1835
1836#[allow(clippy::too_many_arguments)]
1837fn collect_callers_recursive(
1838 store: &impl CallGraphRead,
1839 file: &str,
1840 symbol: &str,
1841 max_depth: usize,
1842 current_depth: usize,
1843 visited: &mut HashSet<(String, String)>,
1844 result: &mut Vec<StoreCallSite>,
1845 depth_limited: &mut bool,
1846 truncated: &mut usize,
1847) -> StoreAdapterResult<()> {
1848 if current_depth >= max_depth {
1849 let target = (file.to_string(), symbol.to_string());
1850 let counts = store.direct_caller_counts_of(std::slice::from_ref(&target))?;
1851 let omitted = counts.get(&target).copied().unwrap_or_default();
1852 if omitted > 0 {
1853 *depth_limited = true;
1854 *truncated += omitted;
1855 }
1856 return Ok(());
1857 }
1858
1859 if !visited.insert((file.to_string(), symbol.to_string())) {
1860 return Ok(());
1861 }
1862
1863 let sites = store.direct_callers_of(Path::new(file), symbol)?;
1864 if sites.is_empty() {
1865 return Ok(());
1866 }
1867 if current_depth + 1 < max_depth {
1868 for site in sites {
1869 result.push(site.clone());
1870 collect_callers_recursive(
1871 store,
1872 &site.caller.file,
1873 &site.caller.symbol,
1874 max_depth,
1875 current_depth + 1,
1876 visited,
1877 result,
1878 depth_limited,
1879 truncated,
1880 )?;
1881 }
1882 } else {
1883 let boundary_targets = sites
1884 .iter()
1885 .map(|site| (site.caller.file.clone(), site.caller.symbol.clone()))
1886 .collect::<BTreeSet<_>>()
1887 .into_iter()
1888 .collect::<Vec<_>>();
1889 let boundary_counts = store.direct_caller_counts_of(&boundary_targets)?;
1890 for site in sites {
1891 result.push(site.clone());
1892 let key = (site.caller.file.clone(), site.caller.symbol.clone());
1893 let omitted = boundary_counts.get(&key).copied().unwrap_or_default();
1894 if omitted > 0 {
1895 *depth_limited = true;
1896 *truncated += omitted;
1897 }
1898 }
1899 }
1900 Ok(())
1901}
1902
1903#[allow(clippy::too_many_arguments)]
1904fn call_tree_inner(
1905 store: &impl CallGraphRead,
1906 current: &ResolvedStoreSymbol,
1907 max_depth: usize,
1908 current_depth: usize,
1909 visited: &mut HashSet<(String, String)>,
1910 adjacency_cache: &mut HashMap<(String, String), Vec<ForwardCall>>,
1911 memoize_adjacency: bool,
1912) -> StoreAdapterResult<StoreCallTreeNode> {
1913 let node = ¤t.representative;
1914 let visit_key = (node.file.clone(), node.symbol.clone());
1915 if visited.contains(&visit_key) {
1916 return Ok(StoreCallTreeNode {
1917 name: node.symbol.clone(),
1918 file: node.file.clone(),
1919 line: node.line,
1920 signature: node.signature.clone(),
1921 resolved: true,
1922 approximate: None,
1923 resolved_by: None,
1924 children: Vec::new(),
1925 depth_limited: false,
1926 truncated: 0,
1927 });
1928 }
1929 visited.insert(visit_key.clone());
1930
1931 let calls = if memoize_adjacency {
1934 if let Some(calls) = adjacency_cache.get(&visit_key) {
1935 calls.clone()
1936 } else {
1937 let calls = forward_calls_for_nodes(store, ¤t.nodes)?;
1938 adjacency_cache.insert(visit_key.clone(), calls.clone());
1939 calls
1940 }
1941 } else {
1942 forward_calls_for_nodes(store, ¤t.nodes)?
1943 };
1944 let mut children = Vec::new();
1945 let mut depth_limited = false;
1946 let mut truncated = 0usize;
1947
1948 if current_depth < max_depth {
1949 for call in calls {
1950 match call {
1951 ForwardCall::Resolved(site) => {
1952 let resolved = resolve_exact_symbol(
1953 store,
1954 &site.target_file,
1955 &site.target_symbol,
1956 site.target.clone(),
1957 )?;
1958 if let Some(child_symbol) = resolved {
1959 let mut child = call_tree_inner(
1960 store,
1961 &child_symbol,
1962 max_depth,
1963 current_depth + 1,
1964 visited,
1965 adjacency_cache,
1966 memoize_adjacency,
1967 )?;
1968 child.approximate = edge_approximate(&site);
1969 child.resolved_by = edge_resolved_by(&site);
1970 depth_limited |= child.depth_limited;
1971 truncated += child.truncated;
1972 children.push(child);
1973 } else {
1974 children.push(StoreCallTreeNode {
1975 name: site.target_symbol.clone(),
1976 file: site.target_file.clone(),
1977 line: site.line,
1978 signature: None,
1979 resolved: false,
1980 approximate: edge_approximate(&site),
1981 resolved_by: edge_resolved_by(&site),
1982 children: Vec::new(),
1983 depth_limited: false,
1984 truncated: 0,
1985 });
1986 }
1987 }
1988 ForwardCall::Unresolved(call) => children.push(StoreCallTreeNode {
1989 name: call.symbol,
1990 file: call.caller.file,
1991 line: call.line,
1992 signature: None,
1993 resolved: false,
1994 approximate: None,
1995 resolved_by: None,
1996 children: Vec::new(),
1997 depth_limited: false,
1998 truncated: 0,
1999 }),
2000 }
2001 }
2002 } else if !calls.is_empty() {
2003 depth_limited = true;
2004 truncated = calls.len();
2005 }
2006
2007 visited.remove(&visit_key);
2008 Ok(StoreCallTreeNode {
2009 name: node.symbol.clone(),
2010 file: node.file.clone(),
2011 line: node.line,
2012 signature: node.signature.clone(),
2013 resolved: true,
2014 approximate: None,
2015 resolved_by: None,
2016 children,
2017 depth_limited,
2018 truncated,
2019 })
2020}
2021
2022fn forward_calls_for_nodes(
2023 store: &impl CallGraphRead,
2024 nodes: &[StoreNode],
2025) -> StoreAdapterResult<Vec<ForwardCall>> {
2026 let mut calls = Vec::new();
2027 for node in nodes {
2028 calls.extend(
2029 store
2030 .outgoing_calls_of(node)?
2031 .into_iter()
2032 .map(ForwardCall::Resolved),
2033 );
2034 calls.extend(
2035 store
2036 .unresolved_calls_of(node)?
2037 .into_iter()
2038 .map(ForwardCall::Unresolved),
2039 );
2040 }
2041 calls.sort_by(|left, right| {
2042 left.byte_start()
2043 .cmp(&right.byte_start())
2044 .then(left.line().cmp(&right.line()))
2045 });
2046 let mut seen = BTreeSet::new();
2047 calls.retain(|call| seen.insert(call.call_site_key()));
2048 Ok(calls)
2049}
2050
2051fn forward_resolved_callees(calls: Vec<StoreCallSite>) -> Vec<(StoreNode, EdgeMarker)> {
2052 let mut calls = dedup_call_sites(calls);
2053 calls.sort_by(|left, right| {
2054 left.byte_start
2055 .cmp(&right.byte_start)
2056 .then(left.line.cmp(&right.line))
2057 });
2058
2059 calls
2060 .into_iter()
2061 .filter_map(|site| {
2062 let edge = edge_marker(&site);
2063 site.target.map(|target| (target, edge))
2064 })
2065 .collect()
2066}
2067
2068fn dedup_call_sites(sites: Vec<StoreCallSite>) -> Vec<StoreCallSite> {
2069 let mut seen = HashSet::new();
2070 let mut deduped = Vec::new();
2071 for site in sites {
2072 if seen.insert(call_site_key(&site)) {
2073 deduped.push(site);
2074 }
2075 }
2076 deduped
2077}
2078
2079#[cfg(test)]
2080fn dedup_call_site_count(sites: Vec<StoreCallSite>) -> usize {
2081 sites
2082 .into_iter()
2083 .map(|site| call_site_key(&site))
2084 .collect::<HashSet<_>>()
2085 .len()
2086}
2087
2088fn call_site_key(site: &StoreCallSite) -> (String, u32, String, String) {
2089 (
2090 site.caller.file.clone(),
2091 site.line,
2092 site.target_file.clone(),
2093 site.target_symbol.clone(),
2094 )
2095}
2096
2097fn trace_to_symbol_hop(node: &StoreNode) -> StoreTraceToSymbolHop {
2098 trace_to_symbol_hop_with_edge(node, EdgeMarker::default())
2099}
2100
2101fn trace_to_symbol_hop_with_edge(node: &StoreNode, edge: EdgeMarker) -> StoreTraceToSymbolHop {
2102 StoreTraceToSymbolHop {
2103 symbol: node.symbol.clone(),
2104 file: node.file.clone(),
2105 line: node.line,
2106 approximate: edge.approximate,
2107 resolved_by: edge.resolved_by,
2108 }
2109}
2110
2111fn trace_to_symbol_matches_target(
2112 file: &str,
2113 symbol: &str,
2114 to_symbol: &str,
2115 to_file: Option<&str>,
2116) -> bool {
2117 if !(symbol == to_symbol || unqualified_name(symbol) == to_symbol) {
2118 return false;
2119 }
2120 match to_file {
2121 Some(target_file) => file == target_file,
2122 None => true,
2123 }
2124}
2125
2126fn unqualified_name(symbol: &str) -> &str {
2127 symbol.rsplit("::").next().unwrap_or(symbol)
2128}
2129
2130fn read_source_line(path: &Path, line: u32) -> Option<String> {
2131 let source = std::fs::read_to_string(path).ok()?;
2132 source
2133 .lines()
2134 .nth(line.saturating_sub(1) as usize)
2135 .map(|line| line.trim().to_string())
2136}
2137
2138fn display_file_for_error(store: &impl CallGraphRead, file: &Path) -> String {
2139 absolute_file(store, file).display().to_string()
2140}
2141
2142fn relative_file(store: &impl CallGraphRead, file: &Path) -> String {
2143 let absolute = absolute_file(store, file);
2144 absolute
2145 .strip_prefix(store.project_root())
2146 .unwrap_or(&absolute)
2147 .to_string_lossy()
2148 .replace('\\', "/")
2149}
2150
2151fn absolute_file(store: &impl CallGraphRead, file: &Path) -> PathBuf {
2152 let full_path = if file.is_relative() {
2153 store.project_root().join(file)
2154 } else {
2155 file.to_path_buf()
2156 };
2157 std::fs::canonicalize(&full_path).unwrap_or(full_path)
2158}
2159
2160#[cfg(test)]
2161mod trace_to_tests {
2162 use super::*;
2163 use crate::callgraph_store::{
2164 Result as CallGraphResult, StoreCallersResult as RawCallersResult,
2165 StoreImpactResult as RawImpactResult, StoredEdge,
2166 };
2167 use std::cell::RefCell;
2168
2169 struct CountingStore {
2170 root: PathBuf,
2171 sqlite_path: PathBuf,
2172 nodes: HashMap<(String, String), StoreNode>,
2173 callers: HashMap<(String, String), Vec<StoreCallSite>>,
2174 outgoing: HashMap<(String, String), Vec<StoreCallSite>>,
2175 caller_queries: RefCell<HashMap<(String, String), usize>>,
2176 forward_query_count: RefCell<usize>,
2177 frontier_query_count: RefCell<usize>,
2178 caller_count_queries: RefCell<usize>,
2179 caller_count_targets: RefCell<usize>,
2180 }
2181
2182 impl CountingStore {
2183 fn new() -> Self {
2184 Self {
2185 root: PathBuf::from("/repo"),
2186 sqlite_path: PathBuf::from("/repo/callgraph.sqlite"),
2187 nodes: HashMap::new(),
2188 callers: HashMap::new(),
2189 outgoing: HashMap::new(),
2190 caller_queries: RefCell::new(HashMap::new()),
2191 forward_query_count: RefCell::new(0),
2192 frontier_query_count: RefCell::new(0),
2193 caller_count_queries: RefCell::new(0),
2194 caller_count_targets: RefCell::new(0),
2195 }
2196 }
2197
2198 fn add_node(&mut self, node: StoreNode) {
2199 self.nodes
2200 .insert((node.file.clone(), node.symbol.clone()), node);
2201 }
2202
2203 fn add_caller(&mut self, target: &StoreNode, caller: &StoreNode) {
2204 self.add_caller_at(target, caller, caller.line);
2205 }
2206
2207 fn add_caller_at(&mut self, target: &StoreNode, caller: &StoreNode, line: u32) {
2208 self.callers
2209 .entry((target.file.clone(), target.symbol.clone()))
2210 .or_default()
2211 .push(StoreCallSite {
2212 caller: caller.clone(),
2213 target_file: target.file.clone(),
2214 target_symbol: target.symbol.clone(),
2215 target: Some(target.clone()),
2216 line,
2217 byte_start: 0,
2218 byte_end: 1,
2219 resolved: true,
2220 provenance: TRACE_DATA_RESOLVER_PROVENANCE.to_string(),
2221 });
2222 }
2223
2224 fn add_outgoing(&mut self, caller: &StoreNode, target: &StoreNode) {
2225 self.outgoing
2226 .entry((caller.file.clone(), caller.symbol.clone()))
2227 .or_default()
2228 .push(StoreCallSite {
2229 caller: caller.clone(),
2230 target_file: target.file.clone(),
2231 target_symbol: target.symbol.clone(),
2232 target: Some(target.clone()),
2233 line: target.line,
2234 byte_start: 0,
2235 byte_end: 1,
2236 resolved: true,
2237 provenance: TRACE_DATA_RESOLVER_PROVENANCE.to_string(),
2238 });
2239 }
2240
2241 fn total_forward_queries(&self) -> usize {
2242 *self.forward_query_count.borrow()
2243 }
2244
2245 fn reset_forward_queries(&self) {
2246 *self.forward_query_count.borrow_mut() = 0;
2247 }
2248
2249 fn total_frontier_queries(&self) -> usize {
2250 *self.frontier_query_count.borrow()
2251 }
2252
2253 fn total_caller_queries(&self) -> usize {
2254 self.caller_queries.borrow().values().sum()
2255 }
2256
2257 fn total_caller_count_queries(&self) -> usize {
2258 *self.caller_count_queries.borrow()
2259 }
2260
2261 fn caller_count_target_count(&self) -> usize {
2262 *self.caller_count_targets.borrow()
2263 }
2264
2265 fn reset_query_counts(&self) {
2266 self.caller_queries.borrow_mut().clear();
2267 *self.caller_count_queries.borrow_mut() = 0;
2268 *self.caller_count_targets.borrow_mut() = 0;
2269 }
2270 }
2271
2272 impl CallGraphRead for CountingStore {
2273 fn project_root(&self) -> &Path {
2274 &self.root
2275 }
2276
2277 fn project_key(&self) -> &str {
2278 "test-project"
2279 }
2280
2281 fn sqlite_path(&self) -> &Path {
2282 &self.sqlite_path
2283 }
2284
2285 fn is_current(&self) -> bool {
2286 true
2287 }
2288
2289 fn edge_snapshot(&self) -> CallGraphResult<BTreeSet<StoredEdge>> {
2290 unreachable!("not used by trace_to_result")
2291 }
2292
2293 fn indexed_file_count(&self) -> CallGraphResult<usize> {
2294 Ok(self.nodes.len())
2295 }
2296
2297 fn node_for(&self, file_rel: &Path, symbol: &str) -> CallGraphResult<StoreNode> {
2298 Ok(self
2299 .nodes_for(file_rel, symbol)?
2300 .into_iter()
2301 .next()
2302 .expect("fixture node"))
2303 }
2304
2305 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> CallGraphResult<Vec<StoreNode>> {
2306 let key = (
2307 file_rel.to_string_lossy().replace('\\', "/"),
2308 symbol.to_string(),
2309 );
2310 Ok(self.nodes.get(&key).cloned().into_iter().collect())
2311 }
2312
2313 fn nodes_matching(&self, symbol: &str) -> CallGraphResult<Vec<StoreNode>> {
2314 Ok(self
2315 .nodes
2316 .values()
2317 .filter(|node| node.symbol == symbol)
2318 .cloned()
2319 .collect())
2320 }
2321
2322 fn direct_callers_of(
2323 &self,
2324 file_rel: &Path,
2325 symbol: &str,
2326 ) -> CallGraphResult<Vec<StoreCallSite>> {
2327 let key = (
2328 file_rel.to_string_lossy().replace('\\', "/"),
2329 symbol.to_string(),
2330 );
2331 *self
2332 .caller_queries
2333 .borrow_mut()
2334 .entry(key.clone())
2335 .or_default() += 1;
2336 Ok(self.callers.get(&key).cloned().unwrap_or_default())
2337 }
2338
2339 fn direct_caller_counts_of(
2340 &self,
2341 targets: &[(String, String)],
2342 ) -> CallGraphResult<HashMap<(String, String), usize>> {
2343 *self.caller_count_queries.borrow_mut() += 1;
2344 *self.caller_count_targets.borrow_mut() = targets.len();
2345 Ok(targets
2346 .iter()
2347 .cloned()
2348 .map(|target| {
2349 let count = self
2350 .callers
2351 .get(&target)
2352 .cloned()
2353 .map(dedup_call_site_count)
2354 .unwrap_or_default();
2355 (target, count)
2356 })
2357 .collect())
2358 }
2359
2360 fn callers_of(
2361 &self,
2362 _file_rel: &Path,
2363 _symbol: &str,
2364 _depth: usize,
2365 ) -> CallGraphResult<RawCallersResult> {
2366 unreachable!("not used by trace_to_result")
2367 }
2368
2369 fn impact_of(
2370 &self,
2371 _file_rel: &Path,
2372 _symbol: &str,
2373 _depth: usize,
2374 ) -> CallGraphResult<RawImpactResult> {
2375 unreachable!("not used by trace_to_result")
2376 }
2377
2378 fn outgoing_calls_of(&self, node: &StoreNode) -> CallGraphResult<Vec<StoreCallSite>> {
2379 *self.forward_query_count.borrow_mut() += 1;
2380 Ok(self
2381 .outgoing
2382 .get(&(node.file.clone(), node.symbol.clone()))
2383 .cloned()
2384 .unwrap_or_default())
2385 }
2386
2387 fn outgoing_calls_for_symbols(
2388 &self,
2389 sources: &[(String, String)],
2390 ) -> CallGraphResult<HashMap<(String, String), Vec<StoreCallSite>>> {
2391 *self.frontier_query_count.borrow_mut() += 1;
2392 Ok(sources
2393 .iter()
2394 .cloned()
2395 .map(|source| {
2396 let calls = self.outgoing.get(&source).cloned().unwrap_or_default();
2397 (source, calls)
2398 })
2399 .collect())
2400 }
2401
2402 fn resolved_self_calls_of(&self, _node: &StoreNode) -> CallGraphResult<Vec<StoreCallSite>> {
2403 unreachable!("not used by these adapter tests")
2404 }
2405
2406 fn unresolved_calls_of(
2407 &self,
2408 _node: &StoreNode,
2409 ) -> CallGraphResult<Vec<StoreUnresolvedCall>> {
2410 *self.forward_query_count.borrow_mut() += 1;
2411 Ok(Vec::new())
2412 }
2413
2414 fn call_tree(
2415 &self,
2416 _file_rel: &Path,
2417 _symbol: &str,
2418 _depth: usize,
2419 ) -> CallGraphResult<callgraph::CallTreeNode> {
2420 unreachable!("not used by trace_to_result")
2421 }
2422
2423 fn trace_to(
2424 &self,
2425 _file_rel: &Path,
2426 _symbol: &str,
2427 _max_depth: usize,
2428 ) -> CallGraphResult<callgraph::TraceToResult> {
2429 unreachable!("not used by trace_to_result")
2430 }
2431
2432 fn trace_to_symbol_candidates(
2433 &self,
2434 _to_symbol: &str,
2435 ) -> CallGraphResult<Vec<TraceToSymbolCandidate>> {
2436 unreachable!("not used by trace_to_result")
2437 }
2438
2439 fn trace_to_symbol(
2440 &self,
2441 _file_rel: &Path,
2442 _symbol: &str,
2443 _to_symbol: &str,
2444 _to_file: Option<&Path>,
2445 _max_depth: usize,
2446 ) -> CallGraphResult<callgraph::TraceToSymbolResult> {
2447 unreachable!("not used by trace_to_result")
2448 }
2449 }
2450
2451 fn node(symbol: &str, is_entry_point: bool) -> StoreNode {
2452 StoreNode::for_test(&format!("{symbol}.ts"), symbol, is_entry_point)
2453 }
2454
2455 fn layered_store(width: usize, layers: usize) -> (CountingStore, StoreNode) {
2456 let mut store = CountingStore::new();
2457 let target = node("target", false);
2458 store.add_node(target.clone());
2459 let mut previous = vec![target.clone()];
2460 for layer in 1..=layers {
2461 let current = (0..width)
2462 .map(|index| node(&format!("layer_{layer}_{index}"), layer == layers))
2463 .collect::<Vec<_>>();
2464 for caller in ¤t {
2465 store.add_node(caller.clone());
2466 }
2467 for target_node in &previous {
2468 for caller in ¤t {
2469 store.add_caller(target_node, caller);
2470 }
2471 }
2472 previous = current;
2473 }
2474 (store, target)
2475 }
2476
2477 fn converging_call_tree_store(width: usize) -> (CountingStore, StoreNode) {
2478 let mut store = CountingStore::new();
2479 let root = node("root", false);
2480 let helper = node("helper", false);
2481 let leaf = node("leaf", false);
2482 for fixture_node in [&root, &helper, &leaf] {
2483 store.add_node(fixture_node.clone());
2484 }
2485 store.add_outgoing(&helper, &leaf);
2486
2487 for index in 0..width {
2488 let handler = node(&format!("handler_{index}"), false);
2489 store.add_node(handler.clone());
2490 store.add_outgoing(&root, &handler);
2491 store.add_outgoing(&handler, &helper);
2492 }
2493 (store, root)
2494 }
2495
2496 fn call_tree_node_count(tree: &StoreCallTreeNode) -> usize {
2497 1 + tree
2498 .children
2499 .iter()
2500 .map(call_tree_node_count)
2501 .sum::<usize>()
2502 }
2503
2504 #[test]
2505 fn call_tree_memoizes_only_adjacency_and_preserves_rendered_tree() {
2506 let (store, root) = converging_call_tree_store(200);
2507
2508 let memoized = call_tree_result(&store, Path::new(&root.file), &root.symbol, 3, true)
2509 .expect("memoized call tree");
2510 let memoized_queries = store.total_forward_queries();
2511
2512 store.reset_forward_queries();
2513 let resolved_root = ResolvedStoreSymbol {
2514 representative: root.clone(),
2515 nodes: vec![root],
2516 };
2517 let mut visited = HashSet::new();
2518 let mut unused_cache = HashMap::new();
2519 let uncached = call_tree_inner(
2520 &store,
2521 &resolved_root,
2522 3,
2523 0,
2524 &mut visited,
2525 &mut unused_cache,
2526 false,
2527 )
2528 .expect("uncached call tree");
2529 let uncached_queries = store.total_forward_queries();
2530
2531 assert_eq!(call_tree_node_count(&memoized), 601);
2532 assert_eq!(
2533 serde_json::to_vec(&memoized).expect("serialize memoized tree"),
2534 serde_json::to_vec(&uncached).expect("serialize uncached tree"),
2535 "adjacency memoization must not change rendered call-tree bytes"
2536 );
2537 assert_eq!(uncached_queries, 1_202);
2538 assert_eq!(memoized_queries, 406);
2539 }
2540
2541 #[test]
2542 fn batched_boundary_counts_preserve_serialized_callers_and_impact_contract() {
2543 let mut store = CountingStore::new();
2544 let target = node("target", false);
2545 let boundary = node("hubCaller", false);
2546 let upstream_a = node("upstreamA", true);
2547 let upstream_b = node("upstreamB", true);
2548 for fixture_node in [&target, &boundary, &upstream_a, &upstream_b] {
2549 store.add_node(fixture_node.clone());
2550 }
2551 for line in 1..=21 {
2552 store.add_caller_at(&target, &boundary, line);
2553 }
2554 store.add_caller(&boundary, &upstream_a);
2555 store.add_caller(&boundary, &upstream_b);
2556
2557 let callers = callers_result(&store, Path::new(&target.file), &target.symbol, 1, true)
2558 .expect("callers result");
2559 assert_eq!(store.total_caller_queries(), 1);
2560 assert_eq!(store.total_caller_count_queries(), 1);
2561 assert_eq!(store.caller_count_target_count(), 1);
2562 assert_eq!(
2563 serde_json::to_string(&callers).expect("serialize callers result"),
2564 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}"#
2565 );
2566
2567 store.reset_query_counts();
2568 let impact = impact_result(&store, Path::new(&target.file), &target.symbol, 1, true)
2569 .expect("impact result");
2570 assert_eq!(store.total_caller_queries(), 1);
2571 assert_eq!(store.total_caller_count_queries(), 1);
2572 assert_eq!(store.caller_count_target_count(), 1);
2573 assert_eq!(
2574 serde_json::to_string(&impact).expect("serialize impact result"),
2575 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}"#
2576 );
2577 }
2578
2579 #[test]
2580 fn trace_to_symbol_batches_frontiers_without_changing_shortest_path() {
2581 let mut store = CountingStore::new();
2582 let origin = node("origin", false);
2583 let slow = node("slow", false);
2584 let slow_middle = node("slowMiddle", false);
2585 let fast = node("fast", false);
2586 let target = node("target", false);
2587 for fixture_node in [&origin, &slow, &slow_middle, &fast, &target] {
2588 store.add_node(fixture_node.clone());
2589 }
2590 store.add_outgoing(&origin, &slow);
2591 store.add_outgoing(&origin, &fast);
2592 store.add_outgoing(&slow, &slow_middle);
2593 store.add_outgoing(&slow_middle, &target);
2594 store.add_outgoing(&fast, &target);
2595
2596 let result = trace_to_symbol_result(
2597 &store,
2598 Path::new(&origin.file),
2599 &origin.symbol,
2600 &target.symbol,
2601 None,
2602 10,
2603 true,
2604 )
2605 .expect("trace-to-symbol result");
2606 let symbols = result
2607 .path
2608 .expect("shortest path")
2609 .into_iter()
2610 .map(|hop| hop.symbol)
2611 .collect::<Vec<_>>();
2612
2613 assert_eq!(symbols, vec!["origin", "fast", "target"]);
2614 assert_eq!(store.total_frontier_queries(), 2);
2615 assert_eq!(store.total_forward_queries(), 0);
2616 }
2617
2618 #[test]
2619 fn trace_to_caches_callers_for_convergent_path_prefixes() {
2620 let (store, target) = layered_store(2, 3);
2621
2622 let (result, expansions) = trace_to_result_with_budget(
2623 &store,
2624 Path::new(&target.file),
2625 &target.symbol,
2626 10,
2627 true,
2628 100,
2629 )
2630 .expect("trace result");
2631
2632 assert_eq!(result.total_paths, 8);
2633 assert!(!result.total_paths_is_lower_bound);
2634 assert_eq!(expansions, 15);
2635 assert_eq!(store.total_caller_queries(), 7);
2636 assert!(store
2637 .caller_queries
2638 .borrow()
2639 .values()
2640 .all(|queries| *queries == 1));
2641 }
2642
2643 #[test]
2644 fn trace_to_budget_returns_valid_paths_and_marks_counts_as_lower_bounds() {
2645 let (store, target) = layered_store(2, 3);
2646
2647 let (result, expansions) = trace_to_result_with_budget(
2648 &store,
2649 Path::new(&target.file),
2650 &target.symbol,
2651 10,
2652 true,
2653 10,
2654 )
2655 .expect("trace result");
2656
2657 assert!(result.total_paths_is_lower_bound);
2658 assert!(result.total_paths > 0);
2659 assert!(expansions <= 10);
2660 assert!(store.total_caller_queries() <= 10);
2661 let summary = result.hub_summary.expect("lower-bound summary");
2662 assert!(summary.counts_are_lower_bounds);
2663 assert!(summary.message.contains("at least"));
2664 for path in result.paths {
2665 assert!(path.hops.first().is_some_and(|hop| hop.is_entry_point));
2666 assert_eq!(
2667 path.hops.last().map(|hop| hop.symbol.as_str()),
2668 Some("target")
2669 );
2670 }
2671 }
2672
2673 #[test]
2674 fn trace_to_below_budget_preserves_exact_serialized_contract() {
2675 let mut store = CountingStore::new();
2676 let target = node("target", false);
2677 let middle = node("middle", false);
2678 let entry = node("entry", true);
2679 for fixture_node in [&target, &middle, &entry] {
2680 store.add_node(fixture_node.clone());
2681 }
2682 store.add_caller(&target, &middle);
2683 store.add_caller(&middle, &entry);
2684
2685 let (result, _expansions) = trace_to_result_with_budget(
2686 &store,
2687 Path::new(&target.file),
2688 &target.symbol,
2689 10,
2690 true,
2691 100,
2692 )
2693 .expect("trace result");
2694
2695 assert_eq!(
2696 serde_json::to_string(&result).expect("serialize trace result"),
2697 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}"#
2698 );
2699 }
2700}