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
185enum ForwardCall {
186 Resolved(StoreCallSite),
187 Unresolved(StoreUnresolvedCall),
188}
189
190#[derive(Clone)]
191enum TraceForwardCall {
192 Resolved(StoreCallSite),
193 Unresolved(StoreUnresolvedCall),
194}
195
196impl TraceForwardCall {
197 fn byte_start(&self) -> usize {
198 match self {
199 Self::Resolved(site) => site.byte_start,
200 Self::Unresolved(call) => call.byte_start,
201 }
202 }
203
204 fn byte_end(&self) -> usize {
205 match self {
206 Self::Resolved(site) => site.byte_end,
207 Self::Unresolved(call) => call.byte_end,
208 }
209 }
210
211 fn line(&self) -> u32 {
212 match self {
213 Self::Resolved(site) => site.line,
214 Self::Unresolved(call) => call.line,
215 }
216 }
217
218 fn matches_position(&self, byte_start: usize, byte_end: usize) -> bool {
219 self.byte_start() == byte_start && self.byte_end() == byte_end
220 }
221}
222
223impl ForwardCall {
224 fn byte_start(&self) -> usize {
225 match self {
226 Self::Resolved(site) => site.byte_start,
227 Self::Unresolved(call) => call.byte_start,
228 }
229 }
230
231 fn line(&self) -> u32 {
232 match self {
233 Self::Resolved(site) => site.line,
234 Self::Unresolved(call) => call.line,
235 }
236 }
237
238 fn call_site_key(&self) -> (String, u32, String) {
239 match self {
240 Self::Resolved(site) => (
241 site.caller.file.clone(),
242 site.line,
243 format!("{}::{}", site.target_file, site.target_symbol),
244 ),
245 Self::Unresolved(call) => (call.caller.file.clone(), call.line, call.symbol.clone()),
246 }
247 }
248}
249
250#[derive(Clone)]
251struct ResolvedStoreSymbol {
252 representative: StoreNode,
253 nodes: Vec<StoreNode>,
254}
255
256#[derive(Clone)]
257struct TraceElem {
258 node: StoreNode,
259 edge: EdgeMarker,
260}
261
262fn edge_marker(site: &StoreCallSite) -> EdgeMarker {
263 if let Some(resolved_by) = site.supplemental_resolution() {
264 EdgeMarker {
265 approximate: Some(site.approximate()),
266 resolved_by: Some(resolved_by.to_string()),
267 }
268 } else {
269 EdgeMarker::default()
270 }
271}
272
273fn edge_approximate(site: &StoreCallSite) -> Option<bool> {
274 site.supplemental_resolution().map(|_| site.approximate())
275}
276
277fn edge_resolved_by(site: &StoreCallSite) -> Option<String> {
278 site.supplemental_resolution().map(ToString::to_string)
279}
280
281fn test_hidden_summary(
282 kind: &str,
283 total: usize,
284 hidden_tests: usize,
285 shown: usize,
286) -> StoreHubSummary {
287 StoreHubSummary {
288 message: format!(
289 "Next: {total} {kind} ({hidden_tests} in tests, hidden — pass includeTests) — narrow with scope"
290 ),
291 total,
292 hidden_tests,
293 shown,
294 threshold: HUB_SUMMARY_THRESHOLD,
295 limit: HUB_SUMMARY_LIMIT,
296 counts_are_lower_bounds: false,
297 }
298}
299
300fn included_summary(
301 kind: &str,
302 total: usize,
303 hidden_tests: usize,
304 shown: usize,
305) -> StoreHubSummary {
306 let test_note = if hidden_tests == 0 {
307 String::new()
308 } else {
309 format!(" ({hidden_tests} in tests, included)")
310 };
311 StoreHubSummary {
312 message: format!("Next: {total} {kind}{test_note} — showing {shown}; narrow with scope"),
313 total,
314 hidden_tests,
315 shown,
316 threshold: HUB_SUMMARY_THRESHOLD,
317 limit: HUB_SUMMARY_LIMIT,
318 counts_are_lower_bounds: false,
319 }
320}
321
322fn lower_bound_trace_summary(
323 total: usize,
324 hidden_tests: usize,
325 shown: usize,
326 include_tests: bool,
327) -> StoreHubSummary {
328 let test_note = if include_tests {
329 if hidden_tests == 0 {
330 " (test-path count also incomplete)".to_string()
331 } else {
332 format!(" (at least {hidden_tests} in tests, included)")
333 }
334 } else if hidden_tests == 0 {
335 " (additional test paths may be uncounted — pass includeTests)".to_string()
336 } else {
337 format!(" (at least {hidden_tests} in tests, hidden — pass includeTests)")
338 };
339 StoreHubSummary {
340 message: format!(
341 "Next: at least {total} paths{test_note} — showing {shown}; traversal capped; narrow with scope"
342 ),
343 total,
344 hidden_tests,
345 shown,
346 threshold: HUB_SUMMARY_THRESHOLD,
347 limit: HUB_SUMMARY_LIMIT,
348 counts_are_lower_bounds: true,
349 }
350}
351
352fn callsite_is_from_test(site: &StoreCallSite) -> bool {
353 is_test_file(&site.caller.file)
354}
355
356fn trace_path_starts_in_test(path: &StoreTracePath) -> bool {
357 path.hops.first().is_some_and(|hop| is_test_file(&hop.file))
358}
359
360fn dedup_sites_for_summary(sites: Vec<StoreCallSite>) -> Vec<StoreCallSite> {
361 let mut seen = BTreeSet::new();
362 sites
363 .into_iter()
364 .filter(|site| seen.insert((site.caller.symbol.clone(), site.target_symbol.clone())))
365 .collect()
366}
367
368fn trace_path_shape(path: &StoreTracePath) -> Vec<(String, String)> {
369 path.hops
370 .iter()
371 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
372 .collect()
373}
374
375fn dedup_paths_for_summary(paths: Vec<StoreTracePath>) -> Vec<StoreTracePath> {
376 let mut seen = BTreeSet::new();
377 paths
378 .into_iter()
379 .filter(|path| seen.insert(trace_path_shape(path)))
380 .collect()
381}
382
383fn trace_path_order(left: &StoreTracePath, right: &StoreTracePath) -> std::cmp::Ordering {
384 let left_entry = left
385 .hops
386 .first()
387 .map(|hop| hop.symbol.as_str())
388 .unwrap_or("");
389 let right_entry = right
390 .hops
391 .first()
392 .map(|hop| hop.symbol.as_str())
393 .unwrap_or("");
394 left_entry
395 .cmp(right_entry)
396 .then(left.hops.len().cmp(&right.hops.len()))
397}
398
399fn store_trace_path(elems: &[TraceElem]) -> StoreTracePath {
400 let hops = elems
401 .iter()
402 .rev()
403 .enumerate()
404 .map(|(index, elem)| StoreTraceHop {
405 symbol: elem.node.symbol.clone(),
406 file: elem.node.file.clone(),
407 line: elem.node.line,
408 signature: elem.node.signature.clone(),
409 is_entry_point: index == 0 && elem.node.is_entry_point,
410 approximate: elem.edge.approximate,
411 resolved_by: elem.edge.resolved_by.clone(),
412 })
413 .collect();
414 StoreTracePath { hops }
415}
416
417fn retain_trace_path(retained: &mut Vec<StoreTracePath>, path: StoreTracePath) {
418 retained.push(path);
419 if retained.len() <= TRACE_TO_RETAINED_PATH_LIMIT {
420 return;
421 }
422 retained.sort_by(trace_path_order);
423 *retained = dedup_paths_for_summary(std::mem::take(retained))
424 .into_iter()
425 .take(TRACE_TO_RETAINED_PATH_LIMIT)
426 .collect();
427}
428
429fn filter_call_tree_tests(node: &mut StoreCallTreeNode) {
430 node.children.retain(|child| !is_test_file(&child.file));
431 for child in &mut node.children {
432 filter_call_tree_tests(child);
433 }
434}
435
436pub fn callers_result(
437 store: &impl CallGraphRead,
438 file: &Path,
439 symbol: &str,
440 depth: usize,
441 include_tests: bool,
442) -> StoreAdapterResult<StoreCallersResult> {
443 let target = resolve_symbol_query(store, file, symbol)?;
444 let effective_depth = depth.max(1);
445 let mut visited = HashSet::new();
446 let mut sites = Vec::new();
447 let mut depth_limited = false;
448 let mut truncated = 0usize;
449
450 collect_callers_recursive(
451 store,
452 &target.representative.file,
453 &target.representative.symbol,
454 effective_depth,
455 0,
456 &mut visited,
457 &mut sites,
458 &mut depth_limited,
459 &mut truncated,
460 )?;
461
462 let mut sites = dedup_call_sites(sites);
463 sites.sort_by(|left, right| {
464 left.caller
465 .file
466 .cmp(&right.caller.file)
467 .then(left.line.cmp(&right.line))
468 .then(left.caller.symbol.cmp(&right.caller.symbol))
469 });
470 let total_callers = sites.len();
471 let hidden_tests = sites
472 .iter()
473 .filter(|site| callsite_is_from_test(site))
474 .count();
475 let summarize = total_callers > HUB_SUMMARY_THRESHOLD;
476 let visible_sites = sites
477 .into_iter()
478 .filter(|site| include_tests || !callsite_is_from_test(site))
479 .collect::<Vec<_>>();
480 let visible_sites = if summarize {
481 dedup_sites_for_summary(visible_sites)
482 .into_iter()
483 .take(HUB_SUMMARY_LIMIT)
484 .collect::<Vec<_>>()
485 } else {
486 visible_sites
487 };
488 let hub_summary = if summarize {
489 Some(if include_tests {
490 included_summary("callers", total_callers, hidden_tests, visible_sites.len())
491 } else {
492 test_hidden_summary("callers", total_callers, hidden_tests, visible_sites.len())
493 })
494 } else {
495 None
496 };
497 let mut groups: BTreeMap<String, Vec<StoreCallerEntry>> = BTreeMap::new();
498 for site in visible_sites {
499 groups
500 .entry(site.caller.file.clone())
501 .or_default()
502 .push(StoreCallerEntry {
503 symbol: site.caller.symbol.clone(),
504 line: site.line,
505 approximate: edge_approximate(&site),
506 resolved_by: edge_resolved_by(&site),
507 });
508 }
509
510 Ok(StoreCallersResult {
511 symbol: target.representative.symbol,
512 file: target.representative.file,
513 callers: groups
514 .into_iter()
515 .map(|(file, callers)| StoreCallerGroup { file, callers })
516 .collect(),
517 total_callers,
518 hub_summary,
519 scanned_files: store.indexed_file_count()?,
520 depth_limited,
521 truncated,
522 })
523}
524
525pub fn call_tree_result(
526 store: &impl CallGraphRead,
527 file: &Path,
528 symbol: &str,
529 depth: usize,
530 include_tests: bool,
531) -> StoreAdapterResult<StoreCallTreeNode> {
532 let target = resolve_symbol_query(store, file, symbol)?;
533 let mut visited = HashSet::new();
534 let mut tree = call_tree_inner(store, &target, depth, 0, &mut visited)?;
535 if !include_tests {
536 filter_call_tree_tests(&mut tree);
537 }
538 Ok(tree)
539}
540
541pub fn impact_result(
542 store: &impl CallGraphRead,
543 file: &Path,
544 symbol: &str,
545 depth: usize,
546 include_tests: bool,
547) -> StoreAdapterResult<StoreImpactResult> {
548 let target = resolve_symbol_query(store, file, symbol)?;
549 let effective_depth = depth.max(1);
550 let mut visited = HashSet::new();
551 let mut sites = Vec::new();
552 let mut depth_limited = false;
553 let mut truncated = 0usize;
554
555 collect_callers_recursive(
556 store,
557 &target.representative.file,
558 &target.representative.symbol,
559 effective_depth,
560 0,
561 &mut visited,
562 &mut sites,
563 &mut depth_limited,
564 &mut truncated,
565 )?;
566
567 let mut sites = dedup_call_sites(sites);
568 sites.sort_by(|left, right| {
569 left.caller
570 .file
571 .cmp(&right.caller.file)
572 .then(left.line.cmp(&right.line))
573 .then(left.caller.symbol.cmp(&right.caller.symbol))
574 });
575 let total_affected = sites.len();
576 let hidden_tests = sites
577 .iter()
578 .filter(|site| callsite_is_from_test(site))
579 .count();
580 let summarize = total_affected > HUB_SUMMARY_THRESHOLD;
581 let affected_files = sites
582 .iter()
583 .map(|site| site.caller.file.clone())
584 .collect::<BTreeSet<_>>()
585 .len();
586 let visible_sites = sites
587 .into_iter()
588 .filter(|site| include_tests || !callsite_is_from_test(site))
589 .collect::<Vec<_>>();
590 let visible_sites = if summarize {
591 dedup_sites_for_summary(visible_sites)
592 .into_iter()
593 .take(HUB_SUMMARY_LIMIT)
594 .collect::<Vec<_>>()
595 } else {
596 visible_sites
597 };
598 let hub_summary = if summarize {
599 Some(if include_tests {
600 included_summary(
601 "affected callers",
602 total_affected,
603 hidden_tests,
604 visible_sites.len(),
605 )
606 } else {
607 test_hidden_summary(
608 "affected callers",
609 total_affected,
610 hidden_tests,
611 visible_sites.len(),
612 )
613 })
614 } else {
615 None
616 };
617 let target_signature = target.representative.signature.clone();
618 let target_parameters = target_signature
619 .as_deref()
620 .map(|signature| callgraph::extract_parameters(signature, target.representative.lang))
621 .unwrap_or_default();
622
623 let mut callers = Vec::new();
624 for site in visible_sites {
625 callers.push(StoreImpactCaller {
626 caller_symbol: site.caller.symbol.clone(),
627 caller_file: site.caller.file.clone(),
628 line: site.line,
629 signature: site.caller.signature.clone(),
630 is_entry_point: site.caller.is_entry_point,
631 call_expression: read_source_line(
632 &store.project_root().join(&site.caller.file),
633 site.line,
634 ),
635 parameters: site
636 .caller
637 .signature
638 .as_deref()
639 .map(|signature| callgraph::extract_parameters(signature, site.caller.lang))
640 .unwrap_or_default(),
641 approximate: edge_approximate(&site),
642 resolved_by: edge_resolved_by(&site),
643 });
644 }
645 callers.sort_by(|left, right| {
646 left.caller_file
647 .cmp(&right.caller_file)
648 .then(left.line.cmp(&right.line))
649 });
650
651 Ok(StoreImpactResult {
652 symbol: target.representative.symbol,
653 file: target.representative.file,
654 signature: target_signature,
655 parameters: target_parameters,
656 total_affected,
657 affected_files,
658 callers,
659 hub_summary,
660 depth_limited,
661 truncated,
662 })
663}
664
665pub fn trace_to_result(
666 store: &impl CallGraphRead,
667 file: &Path,
668 symbol: &str,
669 max_depth: usize,
670 include_tests: bool,
671) -> StoreAdapterResult<StoreTraceToResult> {
672 trace_to_result_with_budget(
673 store,
674 file,
675 symbol,
676 max_depth,
677 include_tests,
678 TRACE_TO_EXPANSION_BUDGET,
679 )
680 .map(|(result, _)| result)
681}
682
683fn trace_to_result_with_budget(
684 store: &impl CallGraphRead,
685 file: &Path,
686 symbol: &str,
687 max_depth: usize,
688 include_tests: bool,
689 expansion_budget: usize,
690) -> StoreAdapterResult<(StoreTraceToResult, usize)> {
691 let target = resolve_symbol_query(store, file, symbol)?;
692 let effective_max = if max_depth == 0 { 10 } else { max_depth };
693
694 let initial = vec![TraceElem {
695 node: target.representative.clone(),
696 edge: EdgeMarker::default(),
697 }];
698 let mut retained_paths = Vec::new();
699 let mut total_paths = 0usize;
700 let mut hidden_tests = 0usize;
701 if target.representative.is_entry_point {
702 total_paths = 1;
703 let path = store_trace_path(&initial);
704 if trace_path_starts_in_test(&path) {
705 hidden_tests = 1;
706 }
707 if include_tests || hidden_tests == 0 {
708 retain_trace_path(&mut retained_paths, path);
709 }
710 }
711
712 let mut queue = vec![(initial, 0usize)];
713 let mut max_depth_reached = false;
714 let mut truncated_paths = 0usize;
715 let mut expansions = 0usize;
716 let mut budget_exhausted = false;
717 let mut callers_by_symbol: HashMap<(String, String), Vec<StoreCallSite>> = HashMap::new();
718
719 'traversal: while let Some((path, depth)) = queue.pop() {
720 if expansions >= expansion_budget {
721 budget_exhausted = true;
722 break;
723 }
724 expansions += 1;
725 if depth >= effective_max {
726 max_depth_reached = true;
727 continue;
728 }
729 let Some(current) = path.last() else {
730 continue;
731 };
732 let caller_key = (current.node.file.clone(), current.node.symbol.clone());
733 if let std::collections::hash_map::Entry::Vacant(entry) =
734 callers_by_symbol.entry(caller_key.clone())
735 {
736 let callers =
737 dedup_call_sites(store.direct_callers_of(Path::new(&caller_key.0), &caller_key.1)?);
738 entry.insert(callers);
739 }
740 let callers = callers_by_symbol
741 .get(&caller_key)
742 .expect("trace caller cache populated above");
743 if callers.is_empty() {
744 if path.len() > 1 {
745 truncated_paths += 1;
746 }
747 continue;
748 }
749
750 let mut has_new_path = false;
751 for site in callers {
752 if path.iter().any(|elem| {
753 elem.node.file == site.caller.file && elem.node.symbol == site.caller.symbol
754 }) {
755 continue;
756 }
757 has_new_path = true;
758 let mut next_path = path.clone();
759 if let Some(current) = next_path.last_mut() {
760 current.edge = edge_marker(&site);
761 }
762 next_path.push(TraceElem {
763 node: site.caller.clone(),
764 edge: EdgeMarker::default(),
765 });
766 if site.caller.is_entry_point {
767 total_paths = total_paths.saturating_add(1);
768 let completed = store_trace_path(&next_path);
769 let from_test = trace_path_starts_in_test(&completed);
770 if from_test {
771 hidden_tests = hidden_tests.saturating_add(1);
772 }
773 if include_tests || !from_test {
774 retain_trace_path(&mut retained_paths, completed);
775 }
776 }
777 if expansions.saturating_add(queue.len()) >= expansion_budget {
780 budget_exhausted = true;
781 break 'traversal;
782 }
783 queue.push((next_path, depth + 1));
784 }
785 if !has_new_path && path.len() > 1 {
786 truncated_paths += 1;
787 }
788 }
789
790 retained_paths.sort_by(trace_path_order);
791 let summarize = budget_exhausted || total_paths > HUB_SUMMARY_THRESHOLD;
792 let paths = if summarize {
793 dedup_paths_for_summary(retained_paths)
794 .into_iter()
795 .take(HUB_SUMMARY_LIMIT)
796 .collect::<Vec<_>>()
797 } else {
798 retained_paths
799 };
800 let hub_summary = if summarize {
801 Some(if budget_exhausted {
802 lower_bound_trace_summary(total_paths, hidden_tests, paths.len(), include_tests)
803 } else if include_tests {
804 included_summary("paths", total_paths, hidden_tests, paths.len())
805 } else {
806 test_hidden_summary("paths", total_paths, hidden_tests, paths.len())
807 })
808 } else {
809 None
810 };
811
812 let entry_points_found = paths
813 .iter()
814 .filter_map(|path| path.hops.first())
815 .filter(|hop| hop.is_entry_point)
816 .map(|hop| (hop.file.clone(), hop.symbol.clone()))
817 .collect::<HashSet<_>>()
818 .len();
819
820 Ok((
821 StoreTraceToResult {
822 target_symbol: target.representative.symbol,
823 target_file: target.representative.file,
824 total_paths,
825 total_paths_is_lower_bound: budget_exhausted,
826 hub_summary,
827 paths,
828 entry_points_found,
829 max_depth_reached,
830 truncated_paths,
831 },
832 expansions,
833 ))
834}
835
836pub fn ensure_symbol_resolves(
837 store: &impl CallGraphRead,
838 file: &Path,
839 symbol: &str,
840) -> StoreAdapterResult<()> {
841 resolve_symbol_query(store, file, symbol).map(|_| ())
842}
843
844pub fn trace_to_symbol_candidates(
845 store: &impl CallGraphRead,
846 to_symbol: &str,
847) -> StoreAdapterResult<Vec<TraceToSymbolCandidate>> {
848 store.trace_to_symbol_candidates(to_symbol)
849}
850
851pub fn trace_to_symbol_result(
852 store: &impl CallGraphRead,
853 file: &Path,
854 symbol: &str,
855 to_symbol: &str,
856 to_file: Option<&Path>,
857 max_depth: usize,
858 include_tests: bool,
859) -> StoreAdapterResult<StoreTraceToSymbolResult> {
860 let origin = resolve_symbol_query(store, file, symbol)?;
861 let target_file = to_file.map(|path| relative_file(store, path));
862 let effective_max = if max_depth == 0 {
863 10
864 } else {
865 max_depth.min(16)
866 };
867
868 let start_hop = trace_to_symbol_hop(&origin.representative);
869 if trace_to_symbol_matches_target(
870 &origin.representative.file,
871 &origin.representative.symbol,
872 to_symbol,
873 target_file.as_deref(),
874 ) {
875 return Ok(StoreTraceToSymbolResult {
876 path: Some(vec![start_hop]),
877 complete: true,
878 reason: None,
879 });
880 }
881
882 let mut queue = VecDeque::new();
883 queue.push_back((
884 origin.representative.file.clone(),
885 origin.representative.symbol.clone(),
886 vec![start_hop],
887 0usize,
888 ));
889 let mut visited = HashSet::new();
890 visited.insert((
891 origin.representative.file.clone(),
892 origin.representative.symbol.clone(),
893 ));
894 let mut max_depth_exhausted = false;
895
896 while let Some((current_file, current_symbol, path, depth)) = queue.pop_front() {
897 let callees = forward_resolved_callees(store, ¤t_file, ¤t_symbol)?;
898
899 if depth >= effective_max {
900 if callees
901 .iter()
902 .any(|(node, _)| !visited.contains(&(node.file.clone(), node.symbol.clone())))
903 {
904 max_depth_exhausted = true;
905 }
906 continue;
907 }
908
909 for (callee, edge) in callees {
910 if !include_tests && is_test_file(&callee.file) {
911 continue;
912 }
913 if !visited.insert((callee.file.clone(), callee.symbol.clone())) {
914 continue;
915 }
916 let mut next_path = path.clone();
917 next_path.push(trace_to_symbol_hop_with_edge(&callee, edge));
918 if trace_to_symbol_matches_target(
919 &callee.file,
920 &callee.symbol,
921 to_symbol,
922 target_file.as_deref(),
923 ) {
924 return Ok(StoreTraceToSymbolResult {
925 path: Some(next_path),
926 complete: true,
927 reason: None,
928 });
929 }
930 queue.push_back((callee.file, callee.symbol, next_path, depth + 1));
931 }
932 }
933
934 if max_depth_exhausted {
935 Ok(StoreTraceToSymbolResult {
936 path: None,
937 complete: false,
938 reason: Some("max_depth_exhausted".to_string()),
939 })
940 } else {
941 Ok(StoreTraceToSymbolResult {
942 path: None,
943 complete: true,
944 reason: Some("no_path_found".to_string()),
945 })
946 }
947}
948
949pub fn trace_data_result(
950 store: &impl CallGraphRead,
951 file: &Path,
952 symbol: &str,
953 expression: &str,
954 max_depth: usize,
955 symbol_cache: SharedSymbolCache,
956) -> StoreAdapterResult<callgraph::TraceDataResult> {
957 let origin_path = absolute_file(store, file);
958 let origin_file = relative_file(store, &origin_path);
959 let origin_symbol = resolve_symbol_query_with_cache(&origin_path, symbol, &symbol_cache)?;
960
961 let mut hops = Vec::new();
962 let mut depth_limited = false;
963 let mut visited = HashSet::new();
964 trace_data_inner(
965 store,
966 &symbol_cache,
967 &origin_path,
968 &origin_symbol,
969 expression,
970 max_depth,
971 0,
972 &mut hops,
973 &mut depth_limited,
974 &mut visited,
975 )?;
976
977 Ok(callgraph::TraceDataResult {
978 expression: expression.to_string(),
979 origin_file,
980 origin_symbol,
981 hops,
982 depth_limited,
983 })
984}
985
986#[allow(clippy::too_many_arguments)]
987fn trace_data_inner(
988 store: &impl CallGraphRead,
989 symbol_cache: &SharedSymbolCache,
990 file: &Path,
991 symbol: &str,
992 tracking_name: &str,
993 max_depth: usize,
994 current_depth: usize,
995 hops: &mut Vec<callgraph::DataFlowHop>,
996 depth_limited: &mut bool,
997 visited: &mut HashSet<(String, String, String)>,
998) -> StoreAdapterResult<()> {
999 let rel_file = relative_file(store, file);
1000 let visit_key = (
1001 rel_file.clone(),
1002 symbol.to_string(),
1003 tracking_name.to_string(),
1004 );
1005 if visited.contains(&visit_key) {
1006 return Ok(());
1007 }
1008 visited.insert(visit_key);
1009
1010 let current = resolve_exact_symbol(store, &rel_file, symbol, None)?
1011 .ok_or_else(|| CallGraphStoreError::StaleFiles(vec![rel_file.clone()]))?;
1012 let current_calls = trace_forward_calls_for_nodes(store, ¤t.nodes)?;
1013
1014 let source = std::fs::read_to_string(file)?;
1017 let Some(lang) = detect_language(file) else {
1018 return Ok(());
1019 };
1020 let grammar = grammar_for(lang);
1021 let mut parser = Parser::new();
1022 parser
1023 .set_language(&grammar)
1024 .map_err(|error| AftError::ParseError {
1025 message: format!("grammar init failed for {:?}: {}", lang, error),
1026 })?;
1027 let tree = parser
1028 .parse(&source, None)
1029 .ok_or_else(|| AftError::ParseError {
1030 message: format!("parse failed for {}", file.display()),
1031 })?;
1032 let symbols = extract_symbols_from_tree(&source, &tree, lang)?;
1033 let sym_info = symbols
1034 .iter()
1035 .find(|candidate| {
1036 symbol_identity_from_cache(candidate) == symbol || candidate.name == symbol
1037 })
1038 .ok_or_else(|| CallGraphStoreError::StaleFiles(vec![rel_file.clone()]))?;
1039
1040 let body_start = line_col_to_byte(&source, sym_info.range.start_line, sym_info.range.start_col);
1041 let body_end = line_col_to_byte(&source, sym_info.range.end_line, sym_info.range.end_col);
1042 let Some(body_node) = find_node_covering_range(tree.root_node(), body_start, body_end) else {
1043 return Ok(());
1044 };
1045
1046 let mut tracked_names = vec![tracking_name.to_string()];
1047 walk_for_data_flow(
1048 store,
1049 symbol_cache,
1050 body_node,
1051 &source,
1052 ¤t_calls,
1053 &mut tracked_names,
1054 symbol,
1055 &rel_file,
1056 max_depth,
1057 current_depth,
1058 hops,
1059 depth_limited,
1060 visited,
1061 )
1062}
1063
1064#[allow(clippy::too_many_arguments)]
1065fn walk_for_data_flow(
1066 store: &impl CallGraphRead,
1067 symbol_cache: &SharedSymbolCache,
1068 node: Node<'_>,
1069 source: &str,
1070 current_calls: &[TraceForwardCall],
1071 tracked_names: &mut Vec<String>,
1072 symbol: &str,
1073 rel_file: &str,
1074 max_depth: usize,
1075 current_depth: usize,
1076 hops: &mut Vec<callgraph::DataFlowHop>,
1077 depth_limited: &mut bool,
1078 visited: &mut HashSet<(String, String, String)>,
1079) -> StoreAdapterResult<()> {
1080 let kind = node.kind();
1081 let is_var_decl = matches!(
1082 kind,
1083 "variable_declarator"
1084 | "assignment_expression"
1085 | "augmented_assignment_expression"
1086 | "assignment"
1087 | "let_declaration"
1088 | "short_var_declaration"
1089 );
1090
1091 if is_var_decl {
1092 if let Some((new_name, init_text, line, is_approx)) =
1093 extract_assignment_info(node, source, tracked_names)
1094 {
1095 if !is_approx {
1096 hops.push(callgraph::DataFlowHop {
1097 file: rel_file.to_string(),
1098 symbol: symbol.to_string(),
1099 variable: new_name.clone(),
1100 line,
1101 flow_type: "assignment".to_string(),
1102 approximate: false,
1103 });
1104 tracked_names.push(new_name);
1105 } else {
1106 hops.push(callgraph::DataFlowHop {
1107 file: rel_file.to_string(),
1108 symbol: symbol.to_string(),
1109 variable: init_text,
1110 line,
1111 flow_type: "assignment".to_string(),
1112 approximate: true,
1113 });
1114 return Ok(());
1115 }
1116 }
1117 }
1118
1119 if kind == "call_expression" || kind == "call" || kind == "macro_invocation" {
1120 check_call_for_data_flow(
1121 store,
1122 symbol_cache,
1123 node,
1124 source,
1125 current_calls,
1126 tracked_names,
1127 symbol,
1128 rel_file,
1129 max_depth,
1130 current_depth,
1131 hops,
1132 depth_limited,
1133 visited,
1134 )?;
1135 }
1136
1137 let mut cursor = node.walk();
1138 if cursor.goto_first_child() {
1139 loop {
1140 walk_for_data_flow(
1141 store,
1142 symbol_cache,
1143 cursor.node(),
1144 source,
1145 current_calls,
1146 tracked_names,
1147 symbol,
1148 rel_file,
1149 max_depth,
1150 current_depth,
1151 hops,
1152 depth_limited,
1153 visited,
1154 )?;
1155 if !cursor.goto_next_sibling() {
1156 break;
1157 }
1158 }
1159 }
1160 Ok(())
1161}
1162
1163fn extract_assignment_info(
1164 node: Node<'_>,
1165 source: &str,
1166 tracked_names: &[String],
1167) -> Option<(String, String, u32, bool)> {
1168 let kind = node.kind();
1169 let line = node.start_position().row as u32 + 1;
1170
1171 match kind {
1172 "variable_declarator" => {
1173 let name_node = node.child_by_field_name("name")?;
1174 let value_node = node.child_by_field_name("value")?;
1175 let name_text = trace_node_text(name_node, source);
1176 let value_text = trace_node_text(value_node, source);
1177
1178 if name_node.kind() == "object_pattern" || name_node.kind() == "array_pattern" {
1179 if tracked_names
1180 .iter()
1181 .any(|tracked| value_text.contains(tracked))
1182 {
1183 return Some((name_text.clone(), name_text, line, true));
1184 }
1185 return None;
1186 }
1187
1188 if tracked_names.iter().any(|tracked| {
1189 value_text == *tracked
1190 || value_text.starts_with(&format!("{}.", tracked))
1191 || value_text.starts_with(&format!("{}[", tracked))
1192 }) {
1193 return Some((name_text, value_text, line, false));
1194 }
1195 None
1196 }
1197 "assignment_expression" | "augmented_assignment_expression" => {
1198 let left = node.child_by_field_name("left")?;
1199 let right = node.child_by_field_name("right")?;
1200 let left_text = trace_node_text(left, source);
1201 let right_text = trace_node_text(right, source);
1202
1203 if tracked_names.iter().any(|tracked| right_text == *tracked) {
1204 return Some((left_text, right_text, line, false));
1205 }
1206 None
1207 }
1208 "assignment" => {
1209 let left = node.child_by_field_name("left")?;
1210 let right = node.child_by_field_name("right")?;
1211 let left_text = trace_node_text(left, source);
1212 let right_text = trace_node_text(right, source);
1213
1214 if tracked_names.iter().any(|tracked| right_text == *tracked) {
1215 return Some((left_text, right_text, line, false));
1216 }
1217 None
1218 }
1219 "let_declaration" | "short_var_declaration" => {
1220 let left = node
1221 .child_by_field_name("pattern")
1222 .or_else(|| node.child_by_field_name("left"))?;
1223 let right = node
1224 .child_by_field_name("value")
1225 .or_else(|| node.child_by_field_name("right"))?;
1226 let left_text = trace_node_text(left, source);
1227 let right_text = trace_node_text(right, source);
1228
1229 if tracked_names.iter().any(|tracked| right_text == *tracked) {
1230 return Some((left_text, right_text, line, false));
1231 }
1232 None
1233 }
1234 _ => None,
1235 }
1236}
1237
1238#[allow(clippy::too_many_arguments)]
1239fn check_call_for_data_flow(
1240 store: &impl CallGraphRead,
1241 symbol_cache: &SharedSymbolCache,
1242 node: Node<'_>,
1243 source: &str,
1244 current_calls: &[TraceForwardCall],
1245 tracked_names: &[String],
1246 symbol: &str,
1247 rel_file: &str,
1248 max_depth: usize,
1249 current_depth: usize,
1250 hops: &mut Vec<callgraph::DataFlowHop>,
1251 depth_limited: &mut bool,
1252 visited: &mut HashSet<(String, String, String)>,
1253) -> StoreAdapterResult<()> {
1254 let args_node =
1255 find_child_by_kind(node, "arguments").or_else(|| find_child_by_kind(node, "argument_list"));
1256 let Some(args_node) = args_node else {
1257 return Ok(());
1258 };
1259
1260 let mut arg_positions = Vec::new();
1261 let mut arg_idx = 0usize;
1262 let mut cursor = args_node.walk();
1263 if cursor.goto_first_child() {
1264 loop {
1265 let child = cursor.node();
1266 let child_kind = child.kind();
1267 if child_kind == "(" || child_kind == ")" || child_kind == "," {
1268 if !cursor.goto_next_sibling() {
1269 break;
1270 }
1271 continue;
1272 }
1273
1274 let arg_text = trace_node_text(child, source);
1275 if child_kind == "spread_element" || child_kind == "dictionary_splat" {
1276 if tracked_names
1277 .iter()
1278 .any(|tracked| arg_text.contains(tracked))
1279 {
1280 hops.push(callgraph::DataFlowHop {
1281 file: rel_file.to_string(),
1282 symbol: symbol.to_string(),
1283 variable: arg_text,
1284 line: child.start_position().row as u32 + 1,
1285 flow_type: "parameter".to_string(),
1286 approximate: true,
1287 });
1288 }
1289 if !cursor.goto_next_sibling() {
1290 break;
1291 }
1292 arg_idx += 1;
1293 continue;
1294 }
1295
1296 if tracked_names.iter().any(|tracked| arg_text == *tracked) {
1297 arg_positions.push((arg_idx, arg_text));
1298 }
1299
1300 arg_idx += 1;
1301 if !cursor.goto_next_sibling() {
1302 break;
1303 }
1304 }
1305 }
1306
1307 if arg_positions.is_empty() {
1308 return Ok(());
1309 }
1310
1311 let matched_call = current_calls
1312 .iter()
1313 .find(|call| call.matches_position(node.start_byte(), node.end_byte()));
1314
1315 match matched_call {
1316 Some(TraceForwardCall::Resolved(site)) => {
1317 let Some(target) = trace_target_node(store, site)? else {
1318 return Ok(());
1319 };
1320 if target.file != rel_file && current_depth + 1 > max_depth {
1321 *depth_limited = true;
1322 return Ok(());
1323 }
1324 let params = target
1325 .signature
1326 .as_deref()
1327 .map(|signature| callgraph::extract_parameters(signature, target.lang))
1328 .unwrap_or_default();
1329 let target_file = store.project_root().join(&target.file);
1330 for (pos, _tracked) in &arg_positions {
1331 if let Some(param_name) = params.get(*pos) {
1332 hops.push(callgraph::DataFlowHop {
1333 file: target.file.clone(),
1334 symbol: target.symbol.clone(),
1335 variable: param_name.clone(),
1336 line: target.line,
1337 flow_type: "parameter".to_string(),
1338 approximate: false,
1339 });
1340 trace_data_inner(
1341 store,
1342 symbol_cache,
1343 &target_file,
1344 &target.symbol,
1345 param_name,
1346 max_depth,
1347 current_depth + 1,
1348 hops,
1349 depth_limited,
1350 visited,
1351 )?;
1352 }
1353 }
1354 }
1355 Some(TraceForwardCall::Unresolved(call)) => {
1356 push_unresolved_parameter_hops(hops, rel_file, &call.symbol, &arg_positions, node);
1357 }
1358 None => {
1359 let (_full_callee, short_callee) = extract_callee_names(node, source);
1360 if let Some(callee_name) = short_callee {
1361 push_unresolved_parameter_hops(hops, rel_file, &callee_name, &arg_positions, node);
1362 }
1363 }
1364 }
1365
1366 Ok(())
1367}
1368
1369fn push_unresolved_parameter_hops(
1370 hops: &mut Vec<callgraph::DataFlowHop>,
1371 rel_file: &str,
1372 callee_name: &str,
1373 arg_positions: &[(usize, String)],
1374 call_node: Node<'_>,
1375) {
1376 for (_pos, tracked) in arg_positions {
1377 hops.push(callgraph::DataFlowHop {
1378 file: rel_file.to_string(),
1379 symbol: callee_name.to_string(),
1380 variable: tracked.clone(),
1381 line: call_node.start_position().row as u32 + 1,
1382 flow_type: "parameter".to_string(),
1383 approximate: true,
1384 });
1385 }
1386}
1387
1388fn trace_target_node(
1389 store: &impl CallGraphRead,
1390 site: &StoreCallSite,
1391) -> StoreAdapterResult<Option<StoreNode>> {
1392 if let Some(target) = &site.target {
1393 return Ok(Some(target.clone()));
1394 }
1395 resolve_exact_symbol(store, &site.target_file, &site.target_symbol, None)
1396 .map(|resolved| resolved.map(|symbol| symbol.representative))
1397}
1398
1399fn trace_forward_calls_for_nodes(
1400 store: &impl CallGraphRead,
1401 nodes: &[StoreNode],
1402) -> StoreAdapterResult<Vec<TraceForwardCall>> {
1403 let mut calls = Vec::new();
1404 for node in nodes {
1405 calls.extend(
1406 store
1407 .outgoing_calls_of(node)?
1408 .into_iter()
1409 .filter(|site| site.resolved_by() == TRACE_DATA_RESOLVER_PROVENANCE)
1410 .map(TraceForwardCall::Resolved),
1411 );
1412 calls.extend(
1413 store
1414 .resolved_self_calls_of(node)?
1415 .into_iter()
1416 .filter(|site| site.resolved_by() == TRACE_DATA_RESOLVER_PROVENANCE)
1417 .map(TraceForwardCall::Resolved),
1418 );
1419 calls.extend(
1420 store
1421 .unresolved_calls_of(node)?
1422 .into_iter()
1423 .map(TraceForwardCall::Unresolved),
1424 );
1425 }
1426 calls.sort_by(|left, right| {
1427 left.byte_start()
1428 .cmp(&right.byte_start())
1429 .then(left.byte_end().cmp(&right.byte_end()))
1430 .then(left.line().cmp(&right.line()))
1431 });
1432 Ok(calls)
1433}
1434
1435fn resolve_symbol_query_with_cache(
1436 file: &Path,
1437 symbol: &str,
1438 symbol_cache: &SharedSymbolCache,
1439) -> StoreAdapterResult<String> {
1440 let mut parser = FileParser::with_symbol_cache(symbol_cache.clone());
1441 let symbols = parser.extract_symbols(file)?;
1442 let candidates = symbol_query_candidates_from_symbols(&symbols, symbol);
1443 match candidates.as_slice() {
1444 [candidate] => Ok(candidate.clone()),
1445 [] => Err(AftError::SymbolNotFound {
1446 name: symbol.to_string(),
1447 file: file.display().to_string(),
1448 }
1449 .into()),
1450 _ => Err(AftError::AmbiguousSymbol {
1451 name: symbol.to_string(),
1452 candidates,
1453 }
1454 .into()),
1455 }
1456}
1457
1458fn symbol_query_candidates_from_symbols(symbols: &[Symbol], symbol_name: &str) -> Vec<String> {
1459 let mut seen = HashSet::new();
1460 let mut candidates = Vec::new();
1461 let qualified_query = symbol_name.contains("::");
1462
1463 let mut consider = |candidate: String| {
1464 let matches = if qualified_query {
1465 candidate == symbol_name
1466 } else {
1467 candidate == symbol_name || unqualified_name(&candidate) == symbol_name
1468 };
1469 if matches && seen.insert(candidate.clone()) {
1470 candidates.push(candidate);
1471 }
1472 };
1473
1474 for symbol in symbols {
1475 consider(symbol_identity_from_cache(symbol));
1476 if symbol.exported {
1477 consider(symbol.name.clone());
1478 }
1479 }
1480
1481 candidates.sort();
1482 candidates
1483}
1484
1485fn symbol_identity_from_cache(symbol: &Symbol) -> String {
1486 if symbol.scope_chain.is_empty() {
1487 symbol.name.clone()
1488 } else {
1489 format!("{}::{}", symbol.scope_chain.join("::"), symbol.name)
1490 }
1491}
1492
1493fn trace_node_text(node: Node<'_>, source: &str) -> String {
1494 source[node.start_byte()..node.end_byte()].to_string()
1495}
1496
1497fn find_node_covering_range(root: Node<'_>, start: usize, end: usize) -> Option<Node<'_>> {
1498 let mut best = None;
1499 let mut cursor = root.walk();
1500
1501 fn walk_covering<'a>(
1502 cursor: &mut tree_sitter::TreeCursor<'a>,
1503 start: usize,
1504 end: usize,
1505 best: &mut Option<Node<'a>>,
1506 ) {
1507 let node = cursor.node();
1508 if node.start_byte() <= start && node.end_byte() >= end {
1509 *best = Some(node);
1510 if cursor.goto_first_child() {
1511 loop {
1512 walk_covering(cursor, start, end, best);
1513 if !cursor.goto_next_sibling() {
1514 break;
1515 }
1516 }
1517 cursor.goto_parent();
1518 }
1519 }
1520 }
1521
1522 walk_covering(&mut cursor, start, end, &mut best);
1523 best
1524}
1525
1526fn find_child_by_kind<'tree>(node: Node<'tree>, kind: &str) -> Option<Node<'tree>> {
1527 let mut cursor = node.walk();
1528 if cursor.goto_first_child() {
1529 loop {
1530 if cursor.node().kind() == kind {
1531 return Some(cursor.node());
1532 }
1533 if !cursor.goto_next_sibling() {
1534 break;
1535 }
1536 }
1537 }
1538 None
1539}
1540
1541fn extract_callee_names(node: Node<'_>, source: &str) -> (Option<String>, Option<String>) {
1542 let Some(callee) = node.child_by_field_name("function") else {
1543 return (None, None);
1544 };
1545 let full = trace_node_text(callee, source);
1546 let short = if full.contains('.') {
1547 full.rsplit('.').next().unwrap_or(&full).to_string()
1548 } else {
1549 full.clone()
1550 };
1551 (Some(full), Some(short))
1552}
1553
1554pub fn store_error_response(req_id: &str, operation: &str, error: CallGraphStoreError) -> Response {
1555 match error {
1556 CallGraphStoreError::Aft(error) => Response::error(req_id, error.code(), error.to_string()),
1557 CallGraphStoreError::Unavailable(message) => Response::error(
1558 req_id,
1559 "callgraph_unavailable",
1560 format!("{operation}: persisted callgraph store unavailable: {message}"),
1561 ),
1562 CallGraphStoreError::StaleFiles(files) => Response::error(
1563 req_id,
1564 "callgraph_stale",
1565 format!(
1566 "{operation}: persisted callgraph store has stale files: {}",
1567 files.join(", ")
1568 ),
1569 ),
1570 other => Response::error(
1571 req_id,
1572 "callgraph_store_error",
1573 format!("{operation}: persisted callgraph store error: {other}"),
1574 ),
1575 }
1576}
1577
1578pub fn building_response(req_id: &str, operation: &str) -> Response {
1582 Response::error(
1583 req_id,
1584 "callgraph_building",
1585 format!("{operation}: callgraph store is building in the background; retry shortly"),
1586 )
1587}
1588
1589pub fn unavailable_response(req_id: &str, operation: &str, worktree: bool) -> Response {
1590 let message = if worktree {
1591 format!(
1592 "{operation}: persisted callgraph store is unavailable in this read-only worktree; run a callgraph operation in the main checkout to build it first"
1593 )
1594 } else {
1595 format!("{operation}: project not configured — send 'configure' first")
1596 };
1597 let code = if worktree {
1598 "callgraph_unavailable"
1599 } else {
1600 "not_configured"
1601 };
1602 Response::error(req_id, code, message)
1603}
1604
1605fn resolve_symbol_query(
1606 store: &impl CallGraphRead,
1607 file: &Path,
1608 symbol: &str,
1609) -> StoreAdapterResult<ResolvedStoreSymbol> {
1610 let nodes = store.nodes_for(file, symbol)?;
1611 collapse_symbol_nodes(store, file, symbol, nodes)
1612}
1613
1614fn resolve_exact_symbol(
1615 store: &impl CallGraphRead,
1616 file: &str,
1617 symbol: &str,
1618 fallback: Option<StoreNode>,
1619) -> StoreAdapterResult<Option<ResolvedStoreSymbol>> {
1620 let nodes = store
1621 .nodes_for(Path::new(file), symbol)?
1622 .into_iter()
1623 .filter(|node| node.symbol == symbol)
1624 .collect::<Vec<_>>();
1625 if nodes.is_empty() {
1626 return Ok(fallback.map(|node| ResolvedStoreSymbol {
1627 representative: node.clone(),
1628 nodes: vec![node],
1629 }));
1630 }
1631 Ok(Some(collapse_exact_nodes(nodes)))
1632}
1633
1634fn collapse_symbol_nodes(
1635 store: &impl CallGraphRead,
1636 file: &Path,
1637 query: &str,
1638 nodes: Vec<StoreNode>,
1639) -> StoreAdapterResult<ResolvedStoreSymbol> {
1640 let mut by_symbol: BTreeMap<String, Vec<StoreNode>> = BTreeMap::new();
1641 for node in nodes {
1642 by_symbol.entry(node.symbol.clone()).or_default().push(node);
1643 }
1644
1645 match by_symbol.len() {
1646 0 => Err(CallGraphStoreError::Aft(AftError::SymbolNotFound {
1647 name: query.to_string(),
1648 file: display_file_for_error(store, file),
1649 })),
1650 1 => Ok(collapse_exact_nodes(
1651 by_symbol.into_values().next().unwrap_or_default(),
1652 )),
1653 _ => Err(CallGraphStoreError::Aft(AftError::AmbiguousSymbol {
1654 name: query.to_string(),
1655 candidates: by_symbol.into_keys().collect(),
1656 })),
1657 }
1658}
1659
1660fn collapse_exact_nodes(mut nodes: Vec<StoreNode>) -> ResolvedStoreSymbol {
1661 nodes.sort_by(|left, right| {
1662 left.symbol
1663 .cmp(&right.symbol)
1664 .then(left.line.cmp(&right.line))
1665 .then(left.end_line.cmp(&right.end_line))
1666 });
1667 let representative = nodes[0].clone();
1668 ResolvedStoreSymbol {
1669 representative,
1670 nodes,
1671 }
1672}
1673
1674#[allow(clippy::too_many_arguments)]
1675fn collect_callers_recursive(
1676 store: &impl CallGraphRead,
1677 file: &str,
1678 symbol: &str,
1679 max_depth: usize,
1680 current_depth: usize,
1681 visited: &mut HashSet<(String, String)>,
1682 result: &mut Vec<StoreCallSite>,
1683 depth_limited: &mut bool,
1684 truncated: &mut usize,
1685) -> StoreAdapterResult<()> {
1686 if current_depth >= max_depth {
1687 let omitted = dedup_call_site_count(store.direct_callers_of(Path::new(file), symbol)?);
1688 if omitted > 0 {
1689 *depth_limited = true;
1690 *truncated += omitted;
1691 }
1692 return Ok(());
1693 }
1694
1695 if !visited.insert((file.to_string(), symbol.to_string())) {
1696 return Ok(());
1697 }
1698
1699 let sites = store.direct_callers_of(Path::new(file), symbol)?;
1700 for site in sites {
1701 result.push(site.clone());
1702 if current_depth + 1 < max_depth {
1703 collect_callers_recursive(
1704 store,
1705 &site.caller.file,
1706 &site.caller.symbol,
1707 max_depth,
1708 current_depth + 1,
1709 visited,
1710 result,
1711 depth_limited,
1712 truncated,
1713 )?;
1714 } else {
1715 let omitted = dedup_call_site_count(
1716 store.direct_callers_of(Path::new(&site.caller.file), &site.caller.symbol)?,
1717 );
1718 if omitted > 0 {
1719 *depth_limited = true;
1720 *truncated += omitted;
1721 }
1722 }
1723 }
1724 Ok(())
1725}
1726
1727fn call_tree_inner(
1728 store: &impl CallGraphRead,
1729 current: &ResolvedStoreSymbol,
1730 max_depth: usize,
1731 current_depth: usize,
1732 visited: &mut HashSet<(String, String)>,
1733) -> StoreAdapterResult<StoreCallTreeNode> {
1734 let node = ¤t.representative;
1735 let visit_key = (node.file.clone(), node.symbol.clone());
1736 if visited.contains(&visit_key) {
1737 return Ok(StoreCallTreeNode {
1738 name: node.symbol.clone(),
1739 file: node.file.clone(),
1740 line: node.line,
1741 signature: node.signature.clone(),
1742 resolved: true,
1743 approximate: None,
1744 resolved_by: None,
1745 children: Vec::new(),
1746 depth_limited: false,
1747 truncated: 0,
1748 });
1749 }
1750 visited.insert(visit_key.clone());
1751
1752 let calls = forward_calls_for_nodes(store, ¤t.nodes)?;
1753 let mut children = Vec::new();
1754 let mut depth_limited = false;
1755 let mut truncated = 0usize;
1756
1757 if current_depth < max_depth {
1758 for call in calls {
1759 match call {
1760 ForwardCall::Resolved(site) => {
1761 let resolved = resolve_exact_symbol(
1762 store,
1763 &site.target_file,
1764 &site.target_symbol,
1765 site.target.clone(),
1766 )?;
1767 if let Some(child_symbol) = resolved {
1768 let mut child = call_tree_inner(
1769 store,
1770 &child_symbol,
1771 max_depth,
1772 current_depth + 1,
1773 visited,
1774 )?;
1775 child.approximate = edge_approximate(&site);
1776 child.resolved_by = edge_resolved_by(&site);
1777 depth_limited |= child.depth_limited;
1778 truncated += child.truncated;
1779 children.push(child);
1780 } else {
1781 children.push(StoreCallTreeNode {
1782 name: site.target_symbol.clone(),
1783 file: site.target_file.clone(),
1784 line: site.line,
1785 signature: None,
1786 resolved: false,
1787 approximate: edge_approximate(&site),
1788 resolved_by: edge_resolved_by(&site),
1789 children: Vec::new(),
1790 depth_limited: false,
1791 truncated: 0,
1792 });
1793 }
1794 }
1795 ForwardCall::Unresolved(call) => children.push(StoreCallTreeNode {
1796 name: call.symbol,
1797 file: call.caller.file,
1798 line: call.line,
1799 signature: None,
1800 resolved: false,
1801 approximate: None,
1802 resolved_by: None,
1803 children: Vec::new(),
1804 depth_limited: false,
1805 truncated: 0,
1806 }),
1807 }
1808 }
1809 } else if !calls.is_empty() {
1810 depth_limited = true;
1811 truncated = calls.len();
1812 }
1813
1814 visited.remove(&visit_key);
1815 Ok(StoreCallTreeNode {
1816 name: node.symbol.clone(),
1817 file: node.file.clone(),
1818 line: node.line,
1819 signature: node.signature.clone(),
1820 resolved: true,
1821 approximate: None,
1822 resolved_by: None,
1823 children,
1824 depth_limited,
1825 truncated,
1826 })
1827}
1828
1829fn forward_calls_for_nodes(
1830 store: &impl CallGraphRead,
1831 nodes: &[StoreNode],
1832) -> StoreAdapterResult<Vec<ForwardCall>> {
1833 let mut calls = Vec::new();
1834 for node in nodes {
1835 calls.extend(
1836 store
1837 .outgoing_calls_of(node)?
1838 .into_iter()
1839 .map(ForwardCall::Resolved),
1840 );
1841 calls.extend(
1842 store
1843 .unresolved_calls_of(node)?
1844 .into_iter()
1845 .map(ForwardCall::Unresolved),
1846 );
1847 }
1848 calls.sort_by(|left, right| {
1849 left.byte_start()
1850 .cmp(&right.byte_start())
1851 .then(left.line().cmp(&right.line()))
1852 });
1853 let mut seen = BTreeSet::new();
1854 calls.retain(|call| seen.insert(call.call_site_key()));
1855 Ok(calls)
1856}
1857
1858fn forward_resolved_callees(
1859 store: &impl CallGraphRead,
1860 file: &str,
1861 symbol: &str,
1862) -> StoreAdapterResult<Vec<(StoreNode, EdgeMarker)>> {
1863 let Some(current) = resolve_exact_symbol(store, file, symbol, None)? else {
1864 return Ok(Vec::new());
1865 };
1866 let mut calls = Vec::new();
1867 for node in ¤t.nodes {
1868 calls.extend(store.outgoing_calls_of(node)?);
1869 }
1870 calls = dedup_call_sites(calls);
1871 calls.sort_by(|left, right| {
1872 left.byte_start
1873 .cmp(&right.byte_start)
1874 .then(left.line.cmp(&right.line))
1875 });
1876
1877 let mut callees = Vec::new();
1878 for site in calls {
1879 let resolved = resolve_exact_symbol(
1880 store,
1881 &site.target_file,
1882 &site.target_symbol,
1883 site.target.clone(),
1884 )?;
1885 if let Some(target) = resolved {
1886 callees.push((target.representative, edge_marker(&site)));
1887 }
1888 }
1889 Ok(callees)
1890}
1891
1892fn dedup_call_sites(sites: Vec<StoreCallSite>) -> Vec<StoreCallSite> {
1893 let mut seen = HashSet::new();
1894 let mut deduped = Vec::new();
1895 for site in sites {
1896 if seen.insert(call_site_key(&site)) {
1897 deduped.push(site);
1898 }
1899 }
1900 deduped
1901}
1902
1903fn dedup_call_site_count(sites: Vec<StoreCallSite>) -> usize {
1904 sites
1905 .into_iter()
1906 .map(|site| call_site_key(&site))
1907 .collect::<HashSet<_>>()
1908 .len()
1909}
1910
1911fn call_site_key(site: &StoreCallSite) -> (String, u32, String, String) {
1912 (
1913 site.caller.file.clone(),
1914 site.line,
1915 site.target_file.clone(),
1916 site.target_symbol.clone(),
1917 )
1918}
1919
1920fn trace_to_symbol_hop(node: &StoreNode) -> StoreTraceToSymbolHop {
1921 trace_to_symbol_hop_with_edge(node, EdgeMarker::default())
1922}
1923
1924fn trace_to_symbol_hop_with_edge(node: &StoreNode, edge: EdgeMarker) -> StoreTraceToSymbolHop {
1925 StoreTraceToSymbolHop {
1926 symbol: node.symbol.clone(),
1927 file: node.file.clone(),
1928 line: node.line,
1929 approximate: edge.approximate,
1930 resolved_by: edge.resolved_by,
1931 }
1932}
1933
1934fn trace_to_symbol_matches_target(
1935 file: &str,
1936 symbol: &str,
1937 to_symbol: &str,
1938 to_file: Option<&str>,
1939) -> bool {
1940 if !(symbol == to_symbol || unqualified_name(symbol) == to_symbol) {
1941 return false;
1942 }
1943 match to_file {
1944 Some(target_file) => file == target_file,
1945 None => true,
1946 }
1947}
1948
1949fn unqualified_name(symbol: &str) -> &str {
1950 symbol.rsplit("::").next().unwrap_or(symbol)
1951}
1952
1953fn read_source_line(path: &Path, line: u32) -> Option<String> {
1954 let source = std::fs::read_to_string(path).ok()?;
1955 source
1956 .lines()
1957 .nth(line.saturating_sub(1) as usize)
1958 .map(|line| line.trim().to_string())
1959}
1960
1961fn display_file_for_error(store: &impl CallGraphRead, file: &Path) -> String {
1962 absolute_file(store, file).display().to_string()
1963}
1964
1965fn relative_file(store: &impl CallGraphRead, file: &Path) -> String {
1966 let absolute = absolute_file(store, file);
1967 absolute
1968 .strip_prefix(store.project_root())
1969 .unwrap_or(&absolute)
1970 .to_string_lossy()
1971 .replace('\\', "/")
1972}
1973
1974fn absolute_file(store: &impl CallGraphRead, file: &Path) -> PathBuf {
1975 let full_path = if file.is_relative() {
1976 store.project_root().join(file)
1977 } else {
1978 file.to_path_buf()
1979 };
1980 std::fs::canonicalize(&full_path).unwrap_or(full_path)
1981}
1982
1983#[cfg(test)]
1984mod trace_to_tests {
1985 use super::*;
1986 use crate::callgraph_store::{
1987 Result as CallGraphResult, StoreCallersResult as RawCallersResult,
1988 StoreImpactResult as RawImpactResult, StoredEdge,
1989 };
1990 use std::cell::RefCell;
1991
1992 struct CountingStore {
1993 root: PathBuf,
1994 sqlite_path: PathBuf,
1995 nodes: HashMap<(String, String), StoreNode>,
1996 callers: HashMap<(String, String), Vec<StoreCallSite>>,
1997 caller_queries: RefCell<HashMap<(String, String), usize>>,
1998 }
1999
2000 impl CountingStore {
2001 fn new() -> Self {
2002 Self {
2003 root: PathBuf::from("/repo"),
2004 sqlite_path: PathBuf::from("/repo/callgraph.sqlite"),
2005 nodes: HashMap::new(),
2006 callers: HashMap::new(),
2007 caller_queries: RefCell::new(HashMap::new()),
2008 }
2009 }
2010
2011 fn add_node(&mut self, node: StoreNode) {
2012 self.nodes
2013 .insert((node.file.clone(), node.symbol.clone()), node);
2014 }
2015
2016 fn add_caller(&mut self, target: &StoreNode, caller: &StoreNode) {
2017 self.callers
2018 .entry((target.file.clone(), target.symbol.clone()))
2019 .or_default()
2020 .push(StoreCallSite {
2021 caller: caller.clone(),
2022 target_file: target.file.clone(),
2023 target_symbol: target.symbol.clone(),
2024 target: Some(target.clone()),
2025 line: caller.line,
2026 byte_start: 0,
2027 byte_end: 1,
2028 resolved: true,
2029 provenance: TRACE_DATA_RESOLVER_PROVENANCE.to_string(),
2030 });
2031 }
2032
2033 fn total_caller_queries(&self) -> usize {
2034 self.caller_queries.borrow().values().sum()
2035 }
2036 }
2037
2038 impl CallGraphRead for CountingStore {
2039 fn project_root(&self) -> &Path {
2040 &self.root
2041 }
2042
2043 fn project_key(&self) -> &str {
2044 "test-project"
2045 }
2046
2047 fn sqlite_path(&self) -> &Path {
2048 &self.sqlite_path
2049 }
2050
2051 fn is_current(&self) -> bool {
2052 true
2053 }
2054
2055 fn edge_snapshot(&self) -> CallGraphResult<BTreeSet<StoredEdge>> {
2056 unreachable!("not used by trace_to_result")
2057 }
2058
2059 fn indexed_file_count(&self) -> CallGraphResult<usize> {
2060 Ok(self.nodes.len())
2061 }
2062
2063 fn node_for(&self, file_rel: &Path, symbol: &str) -> CallGraphResult<StoreNode> {
2064 Ok(self
2065 .nodes_for(file_rel, symbol)?
2066 .into_iter()
2067 .next()
2068 .expect("fixture node"))
2069 }
2070
2071 fn nodes_for(&self, file_rel: &Path, symbol: &str) -> CallGraphResult<Vec<StoreNode>> {
2072 let key = (
2073 file_rel.to_string_lossy().replace('\\', "/"),
2074 symbol.to_string(),
2075 );
2076 Ok(self.nodes.get(&key).cloned().into_iter().collect())
2077 }
2078
2079 fn nodes_matching(&self, symbol: &str) -> CallGraphResult<Vec<StoreNode>> {
2080 Ok(self
2081 .nodes
2082 .values()
2083 .filter(|node| node.symbol == symbol)
2084 .cloned()
2085 .collect())
2086 }
2087
2088 fn direct_callers_of(
2089 &self,
2090 file_rel: &Path,
2091 symbol: &str,
2092 ) -> CallGraphResult<Vec<StoreCallSite>> {
2093 let key = (
2094 file_rel.to_string_lossy().replace('\\', "/"),
2095 symbol.to_string(),
2096 );
2097 *self
2098 .caller_queries
2099 .borrow_mut()
2100 .entry(key.clone())
2101 .or_default() += 1;
2102 Ok(self.callers.get(&key).cloned().unwrap_or_default())
2103 }
2104
2105 fn callers_of(
2106 &self,
2107 _file_rel: &Path,
2108 _symbol: &str,
2109 _depth: usize,
2110 ) -> CallGraphResult<RawCallersResult> {
2111 unreachable!("not used by trace_to_result")
2112 }
2113
2114 fn impact_of(
2115 &self,
2116 _file_rel: &Path,
2117 _symbol: &str,
2118 _depth: usize,
2119 ) -> CallGraphResult<RawImpactResult> {
2120 unreachable!("not used by trace_to_result")
2121 }
2122
2123 fn outgoing_calls_of(&self, _node: &StoreNode) -> CallGraphResult<Vec<StoreCallSite>> {
2124 unreachable!("not used by trace_to_result")
2125 }
2126
2127 fn resolved_self_calls_of(&self, _node: &StoreNode) -> CallGraphResult<Vec<StoreCallSite>> {
2128 unreachable!("not used by trace_to_result")
2129 }
2130
2131 fn unresolved_calls_of(
2132 &self,
2133 _node: &StoreNode,
2134 ) -> CallGraphResult<Vec<StoreUnresolvedCall>> {
2135 unreachable!("not used by trace_to_result")
2136 }
2137
2138 fn call_tree(
2139 &self,
2140 _file_rel: &Path,
2141 _symbol: &str,
2142 _depth: usize,
2143 ) -> CallGraphResult<callgraph::CallTreeNode> {
2144 unreachable!("not used by trace_to_result")
2145 }
2146
2147 fn trace_to(
2148 &self,
2149 _file_rel: &Path,
2150 _symbol: &str,
2151 _max_depth: usize,
2152 ) -> CallGraphResult<callgraph::TraceToResult> {
2153 unreachable!("not used by trace_to_result")
2154 }
2155
2156 fn trace_to_symbol_candidates(
2157 &self,
2158 _to_symbol: &str,
2159 ) -> CallGraphResult<Vec<TraceToSymbolCandidate>> {
2160 unreachable!("not used by trace_to_result")
2161 }
2162
2163 fn trace_to_symbol(
2164 &self,
2165 _file_rel: &Path,
2166 _symbol: &str,
2167 _to_symbol: &str,
2168 _to_file: Option<&Path>,
2169 _max_depth: usize,
2170 ) -> CallGraphResult<callgraph::TraceToSymbolResult> {
2171 unreachable!("not used by trace_to_result")
2172 }
2173 }
2174
2175 fn node(symbol: &str, is_entry_point: bool) -> StoreNode {
2176 StoreNode::for_test(&format!("{symbol}.ts"), symbol, is_entry_point)
2177 }
2178
2179 fn layered_store(width: usize, layers: usize) -> (CountingStore, StoreNode) {
2180 let mut store = CountingStore::new();
2181 let target = node("target", false);
2182 store.add_node(target.clone());
2183 let mut previous = vec![target.clone()];
2184 for layer in 1..=layers {
2185 let current = (0..width)
2186 .map(|index| node(&format!("layer_{layer}_{index}"), layer == layers))
2187 .collect::<Vec<_>>();
2188 for caller in ¤t {
2189 store.add_node(caller.clone());
2190 }
2191 for target_node in &previous {
2192 for caller in ¤t {
2193 store.add_caller(target_node, caller);
2194 }
2195 }
2196 previous = current;
2197 }
2198 (store, target)
2199 }
2200
2201 #[test]
2202 fn trace_to_caches_callers_for_convergent_path_prefixes() {
2203 let (store, target) = layered_store(2, 3);
2204
2205 let (result, expansions) = trace_to_result_with_budget(
2206 &store,
2207 Path::new(&target.file),
2208 &target.symbol,
2209 10,
2210 true,
2211 100,
2212 )
2213 .expect("trace result");
2214
2215 assert_eq!(result.total_paths, 8);
2216 assert!(!result.total_paths_is_lower_bound);
2217 assert_eq!(expansions, 15);
2218 assert_eq!(store.total_caller_queries(), 7);
2219 assert!(store
2220 .caller_queries
2221 .borrow()
2222 .values()
2223 .all(|queries| *queries == 1));
2224 }
2225
2226 #[test]
2227 fn trace_to_budget_returns_valid_paths_and_marks_counts_as_lower_bounds() {
2228 let (store, target) = layered_store(2, 3);
2229
2230 let (result, expansions) = trace_to_result_with_budget(
2231 &store,
2232 Path::new(&target.file),
2233 &target.symbol,
2234 10,
2235 true,
2236 10,
2237 )
2238 .expect("trace result");
2239
2240 assert!(result.total_paths_is_lower_bound);
2241 assert!(result.total_paths > 0);
2242 assert!(expansions <= 10);
2243 assert!(store.total_caller_queries() <= 10);
2244 let summary = result.hub_summary.expect("lower-bound summary");
2245 assert!(summary.counts_are_lower_bounds);
2246 assert!(summary.message.contains("at least"));
2247 for path in result.paths {
2248 assert!(path.hops.first().is_some_and(|hop| hop.is_entry_point));
2249 assert_eq!(
2250 path.hops.last().map(|hop| hop.symbol.as_str()),
2251 Some("target")
2252 );
2253 }
2254 }
2255
2256 #[test]
2257 fn trace_to_below_budget_preserves_exact_serialized_contract() {
2258 let mut store = CountingStore::new();
2259 let target = node("target", false);
2260 let middle = node("middle", false);
2261 let entry = node("entry", true);
2262 for fixture_node in [&target, &middle, &entry] {
2263 store.add_node(fixture_node.clone());
2264 }
2265 store.add_caller(&target, &middle);
2266 store.add_caller(&middle, &entry);
2267
2268 let (result, _expansions) = trace_to_result_with_budget(
2269 &store,
2270 Path::new(&target.file),
2271 &target.symbol,
2272 10,
2273 true,
2274 100,
2275 )
2276 .expect("trace result");
2277
2278 assert_eq!(
2279 serde_json::to_string(&result).expect("serialize trace result"),
2280 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}"#
2281 );
2282 }
2283}