1use std::collections::{HashMap, HashSet, VecDeque};
8use std::ops::ControlFlow;
9
10use super::concurrent::GraphSnapshot;
11use super::edge::kind::EdgeKind;
12#[cfg(test)]
13use super::edge::kind::ResolvedVia;
14use super::edge::store::StoreEdgeRef;
15use super::materialize::materialize_node;
16use super::node::id::NodeId;
17use super::traversal::{
18 EdgeClassification, MaterializedEdge, TraversalMetadata, TraversalResult, TruncationReason,
19};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum TraversalDirection {
26 Outgoing,
28 Incoming,
30 Both,
32}
33
34#[derive(Debug, Clone)]
39#[allow(clippy::struct_excessive_bools)] pub struct EdgeFilter {
41 pub include_calls: bool,
43 pub include_imports: bool,
45 pub include_references: bool,
47 pub include_inheritance: bool,
49 pub include_structural: bool,
51 pub include_type_edges: bool,
53 pub include_database: bool,
55 pub include_service: bool,
57}
58
59impl EdgeFilter {
60 #[must_use]
62 pub const fn all() -> Self {
63 Self {
64 include_calls: true,
65 include_imports: true,
66 include_references: true,
67 include_inheritance: true,
68 include_structural: true,
69 include_type_edges: true,
70 include_database: true,
71 include_service: true,
72 }
73 }
74
75 #[must_use]
77 pub const fn calls_only() -> Self {
78 Self {
79 include_calls: true,
80 include_imports: false,
81 include_references: false,
82 include_inheritance: false,
83 include_structural: false,
84 include_type_edges: false,
85 include_database: false,
86 include_service: false,
87 }
88 }
89
90 #[must_use]
92 pub const fn calls_and_imports() -> Self {
93 Self {
94 include_calls: true,
95 include_imports: true,
96 include_references: false,
97 include_inheritance: false,
98 include_structural: false,
99 include_type_edges: false,
100 include_database: false,
101 include_service: false,
102 }
103 }
104
105 #[must_use]
107 pub const fn dependency_edges() -> Self {
108 Self {
109 include_calls: true,
110 include_imports: true,
111 include_references: true,
112 include_inheritance: true,
113 include_structural: false,
114 include_type_edges: false,
115 include_database: false,
116 include_service: false,
117 }
118 }
119
120 #[must_use]
122 pub fn accepts(&self, classification: &EdgeClassification) -> bool {
123 match classification {
124 EdgeClassification::Call { .. } => self.include_calls,
125 EdgeClassification::Import { .. } | EdgeClassification::Export { .. } => {
126 self.include_imports
127 }
128 EdgeClassification::Reference => self.include_references,
129 EdgeClassification::Inherits | EdgeClassification::Implements => {
130 self.include_inheritance
131 }
132 EdgeClassification::Contains | EdgeClassification::Defines => self.include_structural,
133 EdgeClassification::TypeOf => self.include_type_edges,
134 EdgeClassification::DatabaseAccess => self.include_database,
135 EdgeClassification::ServiceInteraction => self.include_service,
136 }
137 }
138}
139
140#[derive(Debug, Clone)]
142pub struct TraversalLimits {
143 pub max_depth: u32,
145 pub max_nodes: Option<usize>,
147 pub max_edges: Option<usize>,
149 pub max_paths: Option<usize>,
151}
152
153impl TraversalLimits {
154 #[must_use]
156 pub const fn default_subgraph() -> Self {
157 Self {
158 max_depth: 2,
159 max_nodes: Some(50),
160 max_edges: None,
161 max_paths: None,
162 }
163 }
164
165 #[must_use]
167 pub const fn default_export() -> Self {
168 Self {
169 max_depth: 2,
170 max_nodes: None,
171 max_edges: Some(1000),
172 max_paths: None,
173 }
174 }
175
176 #[must_use]
178 pub const fn default_impact() -> Self {
179 Self {
180 max_depth: 3,
181 max_nodes: Some(500),
182 max_edges: None,
183 max_paths: None,
184 }
185 }
186
187 #[must_use]
189 pub const fn default_trace() -> Self {
190 Self {
191 max_depth: 5,
192 max_nodes: None,
193 max_edges: None,
194 max_paths: Some(5),
195 }
196 }
197}
198
199#[derive(Debug, Clone)]
201pub struct TraversalConfig {
202 pub direction: TraversalDirection,
204 pub edge_filter: EdgeFilter,
206 pub limits: TraversalLimits,
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum FrontierMode {
215 Standard,
217 PathEnumeration,
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum VisitedPolicy {
224 Global,
226 PathLocal,
229}
230
231pub trait TraversalStrategy {
235 fn accept_edge(&mut self, _edge: &StoreEdgeRef, _depth: u32) -> bool {
237 true
238 }
239
240 fn should_enqueue(
242 &mut self,
243 _node_id: NodeId,
244 _from: NodeId,
245 _edge: &EdgeKind,
246 _depth: u32,
247 ) -> bool {
248 true
249 }
250
251 fn on_path_complete(&mut self, _path: &[NodeId]) -> ControlFlow<()> {
254 ControlFlow::Continue(())
255 }
256
257 fn frontier_mode(&self) -> FrontierMode {
259 FrontierMode::Standard
260 }
261
262 fn visited_policy(&self) -> VisitedPolicy {
264 VisitedPolicy::Global
265 }
266
267 fn path_target(&self) -> Option<NodeId> {
269 None
270 }
271}
272
273struct DefaultStrategy;
275impl TraversalStrategy for DefaultStrategy {}
276
277#[must_use]
291pub fn is_followable_edge(kind: &EdgeKind, min_confidence: f64) -> bool {
292 let confidence = match kind {
293 EdgeKind::Calls { .. } | EdgeKind::TraitMethodBinding { .. } => 1.0,
294 EdgeKind::Inherits | EdgeKind::Implements | EdgeKind::SealedPermit => 0.9,
295 EdgeKind::Imports { .. } | EdgeKind::Exports { .. } => 0.8,
296 EdgeKind::References => 0.7,
297 EdgeKind::FfiCall { .. }
298 | EdgeKind::HttpRequest { .. }
299 | EdgeKind::GrpcCall { .. }
300 | EdgeKind::WebAssemblyCall => 0.6,
301 EdgeKind::MacroExpansion { .. } => 0.5,
302 _ => 0.3,
303 };
304 confidence >= min_confidence
305}
306
307pub struct SimplePathStrategy {
314 target: NodeId,
316 min_confidence: f64,
318 allow_cross_language: bool,
320}
321
322impl SimplePathStrategy {
323 #[must_use]
325 pub fn new(target: NodeId, min_confidence: f64, allow_cross_language: bool) -> Self {
326 Self {
327 target,
328 min_confidence,
329 allow_cross_language,
330 }
331 }
332}
333
334impl TraversalStrategy for SimplePathStrategy {
335 fn accept_edge(&mut self, edge: &StoreEdgeRef, _depth: u32) -> bool {
336 is_followable_edge(&edge.kind, self.min_confidence)
337 && (self.allow_cross_language || !edge.kind.is_cross_boundary())
338 }
339
340 fn frontier_mode(&self) -> FrontierMode {
341 FrontierMode::PathEnumeration
342 }
343
344 fn visited_policy(&self) -> VisitedPolicy {
345 VisitedPolicy::PathLocal
346 }
347
348 fn path_target(&self) -> Option<NodeId> {
349 Some(self.target)
350 }
351}
352
353pub struct SccPathStrategy<'a> {
358 scc_data: &'a super::analysis::scc::SccData,
360 cond_dag: &'a super::analysis::condensation::CondensationDag,
362 target: NodeId,
364 target_scc: Option<u32>,
366 min_confidence: f64,
368 allow_cross_language: bool,
370}
371
372impl<'a> SccPathStrategy<'a> {
373 #[must_use]
375 pub fn new(
376 scc_data: &'a super::analysis::scc::SccData,
377 cond_dag: &'a super::analysis::condensation::CondensationDag,
378 target: NodeId,
379 min_confidence: f64,
380 allow_cross_language: bool,
381 ) -> Self {
382 let target_scc = scc_data.scc_of(target);
383 Self {
384 scc_data,
385 cond_dag,
386 target,
387 target_scc,
388 min_confidence,
389 allow_cross_language,
390 }
391 }
392}
393
394impl TraversalStrategy for SccPathStrategy<'_> {
395 fn accept_edge(&mut self, edge: &StoreEdgeRef, _depth: u32) -> bool {
396 is_followable_edge(&edge.kind, self.min_confidence)
397 && (self.allow_cross_language || !edge.kind.is_cross_boundary())
398 }
399
400 fn should_enqueue(
401 &mut self,
402 node_id: NodeId,
403 _from: NodeId,
404 _edge: &EdgeKind,
405 _depth: u32,
406 ) -> bool {
407 let Some(target_scc) = self.target_scc else {
409 return true;
410 };
411
412 let Some(node_scc) = self.scc_data.scc_of(node_id) else {
414 return true;
415 };
416
417 self.cond_dag.can_reach(node_scc, target_scc)
418 }
419
420 fn on_path_complete(&mut self, _path: &[NodeId]) -> ControlFlow<()> {
421 ControlFlow::Continue(())
422 }
423
424 fn frontier_mode(&self) -> FrontierMode {
425 FrontierMode::PathEnumeration
426 }
427
428 fn visited_policy(&self) -> VisitedPolicy {
429 VisitedPolicy::PathLocal
430 }
431
432 fn path_target(&self) -> Option<NodeId> {
433 Some(self.target)
434 }
435}
436
437#[must_use]
444pub fn traverse(
445 snapshot: &GraphSnapshot,
446 seeds: &[NodeId],
447 config: &TraversalConfig,
448 strategy: Option<&mut dyn TraversalStrategy>,
449) -> TraversalResult {
450 let mut default_strategy = DefaultStrategy;
451 let strategy = strategy.unwrap_or(&mut default_strategy);
452
453 let frontier_mode = strategy.frontier_mode();
454 let visited_policy = strategy.visited_policy();
455
456 match (frontier_mode, visited_policy) {
457 (FrontierMode::Standard, _) => {
458 run_standard_bfs(snapshot, seeds, config, strategy)
460 }
461 (FrontierMode::PathEnumeration, VisitedPolicy::PathLocal) => {
462 run_path_bfs(snapshot, seeds, config, strategy)
463 }
464 (FrontierMode::PathEnumeration, VisitedPolicy::Global) => {
465 run_path_bfs(snapshot, seeds, config, strategy)
467 }
468 }
469}
470
471struct RawEdge {
475 source: NodeId,
476 target: NodeId,
477 kind: EdgeKind,
478 depth: u32,
479}
480
481fn run_standard_bfs(
483 snapshot: &GraphSnapshot,
484 seeds: &[NodeId],
485 config: &TraversalConfig,
486 strategy: &mut dyn TraversalStrategy,
487) -> TraversalResult {
488 let mut visited: HashSet<NodeId> = HashSet::new();
489 let mut queue: VecDeque<(NodeId, u32)> = VecDeque::new();
490 let mut raw_edges: Vec<RawEdge> = Vec::new();
491 let mut discovered_order: Vec<NodeId> = Vec::new();
492 let mut truncation: Option<TruncationReason> = None;
493 let mut max_depth_reached = false;
494 let mut nodes_visited: usize = 0;
495
496 for &seed in seeds {
498 if visited.insert(seed) {
499 discovered_order.push(seed);
500 queue.push_back((seed, 0));
501 }
502 }
503
504 'bfs: while let Some((current, depth)) = queue.pop_front() {
505 nodes_visited += 1;
506
507 if depth >= config.limits.max_depth {
508 max_depth_reached = true;
509 continue;
510 }
511
512 if let Some(max_nodes) = config.limits.max_nodes
514 && discovered_order.len() >= max_nodes
515 {
516 truncation = Some(TruncationReason::NodeLimit);
517 break;
518 }
519
520 let edges = collect_edges(snapshot, current, config.direction);
521
522 for edge_ref in &edges {
523 if let Some(max_edges) = config.limits.max_edges
525 && raw_edges.len() >= max_edges
526 {
527 truncation = Some(TruncationReason::EdgeLimit);
528 break 'bfs;
529 }
530
531 let classification = EdgeClassification::from(&edge_ref.kind);
532 if !config.edge_filter.accepts(&classification) {
533 continue;
534 }
535
536 if !strategy.accept_edge(edge_ref, depth) {
537 continue;
538 }
539
540 let next = neighbor_of(edge_ref, current);
542
543 raw_edges.push(RawEdge {
544 source: edge_ref.source,
545 target: edge_ref.target,
546 kind: edge_ref.kind.clone(),
547 depth: depth + 1,
548 });
549
550 if visited.insert(next)
551 && strategy.should_enqueue(next, current, &edge_ref.kind, depth + 1)
552 {
553 if let Some(max_nodes) = config.limits.max_nodes
555 && discovered_order.len() >= max_nodes
556 {
557 truncation = Some(TruncationReason::NodeLimit);
558 break 'bfs;
559 }
560 discovered_order.push(next);
561 queue.push_back((next, depth + 1));
562 }
563 }
564 }
565
566 materialize_result(
567 snapshot,
568 &discovered_order,
569 &raw_edges,
570 None,
571 truncation,
572 max_depth_reached,
573 seeds.len(),
574 nodes_visited,
575 )
576}
577
578#[allow(clippy::too_many_lines)] fn run_path_bfs(
583 snapshot: &GraphSnapshot,
584 seeds: &[NodeId],
585 config: &TraversalConfig,
586 strategy: &mut dyn TraversalStrategy,
587) -> TraversalResult {
588 let target = strategy.path_target();
589 let mut collected_paths: Vec<Vec<NodeId>> = Vec::new();
590 let mut discovered_order: Vec<NodeId> = Vec::new();
591 let mut seen: HashSet<NodeId> = HashSet::new();
592 let mut raw_edges: Vec<RawEdge> = Vec::new();
593 let mut truncation: Option<TruncationReason> = None;
594 let mut max_depth_reached = false;
595 let mut nodes_visited: usize = 0;
596
597 let mut queue: VecDeque<(NodeId, Vec<NodeId>, u32)> = VecDeque::new();
599
600 for &seed in seeds {
601 queue.push_back((seed, vec![seed], 0));
602 if seen.insert(seed) {
603 discovered_order.push(seed);
604 }
605 }
606
607 let use_global_visited = strategy.visited_policy() == VisitedPolicy::Global;
608 let mut global_visited: HashSet<NodeId> = if use_global_visited {
609 seeds.iter().copied().collect()
610 } else {
611 HashSet::new()
612 };
613
614 'bfs: while let Some((current, path, depth)) = queue.pop_front() {
615 nodes_visited += 1;
616
617 if let Some(t) = target
619 && current == t
620 && path.len() > 1
621 {
622 for &node in &path {
624 if seen.insert(node) {
625 discovered_order.push(node);
626 }
627 }
628
629 let control = strategy.on_path_complete(&path);
630 collected_paths.push(path);
631
632 if let Some(max_paths) = config.limits.max_paths
634 && collected_paths.len() >= max_paths
635 {
636 truncation = Some(TruncationReason::PathLimit);
637 break 'bfs;
638 }
639
640 if control.is_break() {
641 break 'bfs;
642 }
643 continue;
644 }
645
646 if depth >= config.limits.max_depth {
647 max_depth_reached = true;
648 continue;
649 }
650
651 if let Some(max_nodes) = config.limits.max_nodes
653 && discovered_order.len() >= max_nodes
654 {
655 truncation = Some(TruncationReason::NodeLimit);
656 break;
657 }
658
659 let edges = collect_edges(snapshot, current, config.direction);
660
661 let mut has_followable_successor = false;
663
664 for edge_ref in &edges {
665 if let Some(max_edges) = config.limits.max_edges
667 && raw_edges.len() >= max_edges
668 {
669 truncation = Some(TruncationReason::EdgeLimit);
670 break 'bfs;
671 }
672
673 let classification = EdgeClassification::from(&edge_ref.kind);
674 if !config.edge_filter.accepts(&classification) {
675 continue;
676 }
677
678 if !strategy.accept_edge(edge_ref, depth) {
679 continue;
680 }
681
682 let next = neighbor_of(edge_ref, current);
683
684 if use_global_visited {
686 if !global_visited.insert(next) {
687 continue;
688 }
689 } else if path.contains(&next) {
690 continue;
691 }
692
693 if !strategy.should_enqueue(next, current, &edge_ref.kind, depth + 1) {
694 continue;
695 }
696
697 has_followable_successor = true;
698
699 raw_edges.push(RawEdge {
700 source: edge_ref.source,
701 target: edge_ref.target,
702 kind: edge_ref.kind.clone(),
703 depth: depth + 1,
704 });
705
706 if let Some(max_nodes) = config.limits.max_nodes
708 && discovered_order.len() >= max_nodes
709 {
710 truncation = Some(TruncationReason::NodeLimit);
711 break 'bfs;
712 }
713
714 if seen.insert(next) {
715 discovered_order.push(next);
716 }
717
718 let mut new_path = path.clone();
719 new_path.push(next);
720 queue.push_back((next, new_path, depth + 1));
721 }
722
723 if target.is_none() && !has_followable_successor && path.len() > 1 {
725 for &node in &path {
726 if seen.insert(node) {
727 discovered_order.push(node);
728 }
729 }
730
731 let control = strategy.on_path_complete(&path);
732 collected_paths.push(path);
733
734 if let Some(max_paths) = config.limits.max_paths
735 && collected_paths.len() >= max_paths
736 {
737 truncation = Some(TruncationReason::PathLimit);
738 break 'bfs;
739 }
740
741 if control.is_break() {
742 break 'bfs;
743 }
744 }
745 }
746
747 let paths_for_result = Some(collected_paths);
748
749 materialize_result(
750 snapshot,
751 &discovered_order,
752 &raw_edges,
753 paths_for_result,
754 truncation,
755 max_depth_reached,
756 seeds.len(),
757 nodes_visited,
758 )
759}
760
761fn collect_edges(
765 snapshot: &GraphSnapshot,
766 node: NodeId,
767 direction: TraversalDirection,
768) -> Vec<StoreEdgeRef> {
769 match direction {
770 TraversalDirection::Outgoing => snapshot.edges().edges_from(node),
771 TraversalDirection::Incoming => snapshot.edges().edges_to(node),
772 TraversalDirection::Both => {
773 let mut edges = snapshot.edges().edges_from(node);
774 edges.extend(snapshot.edges().edges_to(node));
775 edges
776 }
777 }
778}
779
780fn neighbor_of(edge_ref: &StoreEdgeRef, current: NodeId) -> NodeId {
782 if edge_ref.source == current {
783 edge_ref.target
784 } else {
785 edge_ref.source
786 }
787}
788
789#[allow(clippy::too_many_arguments)]
794fn materialize_result(
795 snapshot: &GraphSnapshot,
796 discovered_order: &[NodeId],
797 raw_edges: &[RawEdge],
798 raw_paths: Option<Vec<Vec<NodeId>>>,
799 truncation: Option<TruncationReason>,
800 max_depth_reached: bool,
801 seed_count: usize,
802 nodes_visited: usize,
803) -> TraversalResult {
804 let mut nodes = Vec::with_capacity(discovered_order.len());
806 let mut node_index: HashMap<NodeId, usize> = HashMap::with_capacity(discovered_order.len());
807
808 for &node_id in discovered_order {
809 if node_index.contains_key(&node_id) {
810 continue;
811 }
812 if let Some(materialized) = materialize_node(snapshot, node_id) {
813 let idx = nodes.len();
814 node_index.insert(node_id, idx);
815 nodes.push(materialized);
816 }
817 }
818
819 let mut edges = Vec::with_capacity(raw_edges.len());
821 for raw in raw_edges {
822 if let (Some(&source_idx), Some(&target_idx)) =
823 (node_index.get(&raw.source), node_index.get(&raw.target))
824 {
825 edges.push(MaterializedEdge {
826 source_idx,
827 target_idx,
828 classification: EdgeClassification::from(&raw.kind),
829 raw_kind: raw.kind.clone(),
830 depth: raw.depth,
831 });
832 }
833 }
834
835 edges.dedup_by(|a, b| {
837 a.source_idx == b.source_idx
838 && a.target_idx == b.target_idx
839 && a.classification == b.classification
840 });
841
842 let paths = raw_paths.map(|path_list| {
844 path_list
845 .iter()
846 .filter_map(|path| {
847 let converted: Vec<usize> = path
848 .iter()
849 .filter_map(|node_id| node_index.get(node_id).copied())
850 .collect();
851 if converted.len() == path.len() {
853 Some(converted)
854 } else {
855 None
856 }
857 })
858 .collect()
859 });
860
861 TraversalResult {
862 metadata: TraversalMetadata {
863 truncation,
864 max_depth_reached,
865 seed_count,
866 nodes_visited,
867 total_nodes: nodes.len(),
868 total_edges: edges.len(),
869 },
870 nodes,
871 edges,
872 paths,
873 }
874}
875
876#[cfg(test)]
877mod tests {
878 use super::*;
879 use crate::graph::node::Language;
880 use crate::graph::unified::concurrent::CodeGraph;
881 use crate::graph::unified::node::kind::NodeKind;
882 use crate::graph::unified::storage::arena::NodeEntry;
883
884 use crate::graph::unified::file::FileId;
885
886 struct TestGraph {
888 graph: CodeGraph,
889 file_id: Option<FileId>,
890 }
891
892 impl TestGraph {
893 fn new() -> Self {
894 Self {
895 graph: CodeGraph::new(),
896 file_id: None,
897 }
898 }
899
900 fn ensure_file_id(&mut self) -> FileId {
901 if let Some(fid) = self.file_id {
902 return fid;
903 }
904 let file_path = std::path::PathBuf::from("/kernel-tests/test.rs");
905 let fid = self
906 .graph
907 .files_mut()
908 .register_with_language(&file_path, Some(Language::Rust))
909 .unwrap();
910 self.file_id = Some(fid);
911 fid
912 }
913
914 fn add_node(&mut self, name: &str) -> NodeId {
915 self.add_node_with_kind(name, NodeKind::Function)
916 }
917
918 fn add_node_with_kind(&mut self, name: &str, kind: NodeKind) -> NodeId {
919 let file_id = self.ensure_file_id();
920 let name_id = self.graph.strings_mut().intern(name).unwrap();
921 let qn_id = self
922 .graph
923 .strings_mut()
924 .intern(&format!("test::{name}"))
925 .unwrap();
926
927 let entry = NodeEntry::new(kind, name_id, file_id)
928 .with_qualified_name(qn_id)
929 .with_location(1, 0, 10, 0);
930
931 let node_id = self.graph.nodes_mut().alloc(entry).unwrap();
932 self.graph
933 .indices_mut()
934 .add(node_id, kind, name_id, Some(qn_id), file_id);
935 node_id
936 }
937
938 fn add_call_edge(&mut self, source: NodeId, target: NodeId) {
939 let file_id = self.ensure_file_id();
940 self.graph.edges_mut().add_edge(
941 source,
942 target,
943 EdgeKind::Calls {
944 argument_count: 0,
945 is_async: false,
946 resolved_via: ResolvedVia::Direct,
947 },
948 file_id,
949 );
950 }
951
952 fn add_edge(&mut self, source: NodeId, target: NodeId, kind: EdgeKind) {
953 let file_id = self.ensure_file_id();
954 self.graph
955 .edges_mut()
956 .add_edge(source, target, kind, file_id);
957 }
958
959 fn snapshot(&self) -> GraphSnapshot {
960 self.graph.snapshot()
961 }
962 }
963
964 fn calls_config(depth: u32) -> TraversalConfig {
965 TraversalConfig {
966 direction: TraversalDirection::Outgoing,
967 edge_filter: EdgeFilter::calls_only(),
968 limits: TraversalLimits {
969 max_depth: depth,
970 max_nodes: None,
971 max_edges: None,
972 max_paths: None,
973 },
974 }
975 }
976
977 #[test]
978 fn standard_outgoing_bfs() {
979 let mut tg = TestGraph::new();
980 let a = tg.add_node("a");
981 let b = tg.add_node("b");
982 let c = tg.add_node("c");
983 tg.add_call_edge(a, b);
984 tg.add_call_edge(b, c);
985
986 let snapshot = tg.snapshot();
987 let result = traverse(&snapshot, &[a], &calls_config(3), None);
988
989 assert_eq!(result.nodes.len(), 3);
990 assert_eq!(result.edges.len(), 2);
991 assert!(result.metadata.truncation.is_none());
992 assert_eq!(result.nodes[0].node_id, a);
994 }
995
996 #[test]
997 fn depth_limit() {
998 let mut tg = TestGraph::new();
999 let a = tg.add_node("a");
1000 let b = tg.add_node("b");
1001 let c = tg.add_node("c");
1002 tg.add_call_edge(a, b);
1003 tg.add_call_edge(b, c);
1004
1005 let snapshot = tg.snapshot();
1006 let result = traverse(&snapshot, &[a], &calls_config(1), None);
1007
1008 assert_eq!(result.nodes.len(), 2);
1010 assert_eq!(result.edges.len(), 1);
1011 assert!(result.metadata.max_depth_reached);
1012 }
1013
1014 #[test]
1015 fn node_limit_truncation() {
1016 let mut tg = TestGraph::new();
1017 let a = tg.add_node("a");
1018 let b = tg.add_node("b");
1019 let c = tg.add_node("c");
1020 let d = tg.add_node("d");
1021 tg.add_call_edge(a, b);
1022 tg.add_call_edge(a, c);
1023 tg.add_call_edge(a, d);
1024
1025 let snapshot = tg.snapshot();
1026 let config = TraversalConfig {
1027 direction: TraversalDirection::Outgoing,
1028 edge_filter: EdgeFilter::calls_only(),
1029 limits: TraversalLimits {
1030 max_depth: 5,
1031 max_nodes: Some(2),
1032 max_edges: None,
1033 max_paths: None,
1034 },
1035 };
1036
1037 let result = traverse(&snapshot, &[a], &config, None);
1038
1039 assert_eq!(
1040 result.metadata.truncation,
1041 Some(TruncationReason::NodeLimit)
1042 );
1043 }
1044
1045 #[test]
1046 fn empty_seeds() {
1047 let tg = TestGraph::new();
1048 let snapshot = tg.snapshot();
1049 let result = traverse(&snapshot, &[], &calls_config(3), None);
1050
1051 assert!(result.nodes.is_empty());
1052 assert!(result.edges.is_empty());
1053 assert!(result.metadata.truncation.is_none());
1054 assert_eq!(result.metadata.seed_count, 0);
1055 }
1056
1057 #[test]
1058 fn incoming_bfs() {
1059 let mut tg = TestGraph::new();
1060 let a = tg.add_node("a");
1061 let b = tg.add_node("b");
1062 let c = tg.add_node("c");
1063 tg.add_call_edge(a, b);
1064 tg.add_call_edge(c, b);
1065
1066 let snapshot = tg.snapshot();
1067 let config = TraversalConfig {
1068 direction: TraversalDirection::Incoming,
1069 edge_filter: EdgeFilter::calls_only(),
1070 limits: TraversalLimits {
1071 max_depth: 3,
1072 max_nodes: None,
1073 max_edges: None,
1074 max_paths: None,
1075 },
1076 };
1077
1078 let result = traverse(&snapshot, &[b], &config, None);
1079
1080 assert_eq!(result.nodes.len(), 3);
1082 }
1083
1084 #[test]
1085 fn bidirectional_bfs() {
1086 let mut tg = TestGraph::new();
1087 let a = tg.add_node("a");
1088 let b = tg.add_node("b");
1089 let c = tg.add_node("c");
1090 tg.add_call_edge(a, b);
1091 tg.add_call_edge(b, c);
1092
1093 let snapshot = tg.snapshot();
1094 let config = TraversalConfig {
1095 direction: TraversalDirection::Both,
1096 edge_filter: EdgeFilter::calls_only(),
1097 limits: TraversalLimits {
1098 max_depth: 3,
1099 max_nodes: None,
1100 max_edges: None,
1101 max_paths: None,
1102 },
1103 };
1104
1105 let result = traverse(&snapshot, &[b], &config, None);
1107 assert_eq!(result.nodes.len(), 3);
1108 }
1109
1110 #[test]
1111 fn edge_filtering() {
1112 let mut tg = TestGraph::new();
1113 let a = tg.add_node("a");
1114 let b = tg.add_node("b");
1115 let c = tg.add_node("c");
1116 tg.add_call_edge(a, b);
1117 tg.add_edge(
1118 a,
1119 c,
1120 EdgeKind::Imports {
1121 alias: None,
1122 is_wildcard: false,
1123 },
1124 );
1125
1126 let snapshot = tg.snapshot();
1127
1128 let result = traverse(&snapshot, &[a], &calls_config(3), None);
1130 assert_eq!(result.nodes.len(), 2); let config = TraversalConfig {
1134 direction: TraversalDirection::Outgoing,
1135 edge_filter: EdgeFilter::calls_and_imports(),
1136 limits: TraversalLimits {
1137 max_depth: 3,
1138 max_nodes: None,
1139 max_edges: None,
1140 max_paths: None,
1141 },
1142 };
1143 let result = traverse(&snapshot, &[a], &config, None);
1144 assert_eq!(result.nodes.len(), 3); }
1146
1147 #[test]
1148 fn cycle_handling_standard() {
1149 let mut tg = TestGraph::new();
1150 let a = tg.add_node("a");
1151 let b = tg.add_node("b");
1152 tg.add_call_edge(a, b);
1153 tg.add_call_edge(b, a);
1154
1155 let snapshot = tg.snapshot();
1156 let result = traverse(&snapshot, &[a], &calls_config(10), None);
1157
1158 assert_eq!(result.nodes.len(), 2);
1160 }
1161
1162 #[test]
1163 fn edge_limit_truncation() {
1164 let mut tg = TestGraph::new();
1165 let a = tg.add_node("a");
1166 let b = tg.add_node("b");
1167 let c = tg.add_node("c");
1168 let d = tg.add_node("d");
1169 tg.add_call_edge(a, b);
1170 tg.add_call_edge(a, c);
1171 tg.add_call_edge(a, d);
1172
1173 let snapshot = tg.snapshot();
1174 let config = TraversalConfig {
1175 direction: TraversalDirection::Outgoing,
1176 edge_filter: EdgeFilter::calls_only(),
1177 limits: TraversalLimits {
1178 max_depth: 5,
1179 max_nodes: None,
1180 max_edges: Some(2),
1181 max_paths: None,
1182 },
1183 };
1184
1185 let result = traverse(&snapshot, &[a], &config, None);
1186 assert_eq!(
1187 result.metadata.truncation,
1188 Some(TruncationReason::EdgeLimit)
1189 );
1190 }
1191
1192 #[test]
1193 fn index_invariants_hold() {
1194 let mut tg = TestGraph::new();
1195 let a = tg.add_node("a");
1196 let b = tg.add_node("b");
1197 let c = tg.add_node("c");
1198 tg.add_call_edge(a, b);
1199 tg.add_call_edge(b, c);
1200
1201 let snapshot = tg.snapshot();
1202 let result = traverse(&snapshot, &[a], &calls_config(5), None);
1203
1204 for edge in &result.edges {
1206 assert!(edge.source_idx < result.nodes.len());
1207 assert!(edge.target_idx < result.nodes.len());
1208 }
1209
1210 assert_eq!(result.metadata.total_nodes, result.nodes.len());
1212 assert_eq!(result.metadata.total_edges, result.edges.len());
1213 }
1214
1215 #[test]
1216 fn all_filter_includes_structural() {
1217 let mut tg = TestGraph::new();
1218 let a = tg.add_node("a");
1219 let b = tg.add_node("b");
1220 tg.add_edge(a, b, EdgeKind::Defines);
1221
1222 let snapshot = tg.snapshot();
1223 let config = TraversalConfig {
1224 direction: TraversalDirection::Outgoing,
1225 edge_filter: EdgeFilter::all(),
1226 limits: TraversalLimits {
1227 max_depth: 3,
1228 max_nodes: None,
1229 max_edges: None,
1230 max_paths: None,
1231 },
1232 };
1233 let result = traverse(&snapshot, &[a], &config, None);
1234 assert_eq!(result.nodes.len(), 2);
1235 assert_eq!(result.edges.len(), 1);
1236 assert_eq!(result.edges[0].classification, EdgeClassification::Defines);
1237 }
1238
1239 #[test]
1242 fn path_enumeration_finds_path() {
1243 let mut tg = TestGraph::new();
1244 let a = tg.add_node("pa");
1245 let b = tg.add_node("pb");
1246 let c = tg.add_node("pc");
1247 tg.add_call_edge(a, b);
1248 tg.add_call_edge(b, c);
1249
1250 let snapshot = tg.snapshot();
1251 let config = TraversalConfig {
1252 direction: TraversalDirection::Outgoing,
1253 edge_filter: EdgeFilter::calls_only(),
1254 limits: TraversalLimits {
1255 max_depth: 5,
1256 max_nodes: None,
1257 max_edges: None,
1258 max_paths: Some(10),
1259 },
1260 };
1261
1262 let mut strategy = SimplePathStrategy::new(c, 0.0, true);
1263 let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1264
1265 assert!(result.paths.is_some());
1266 let paths = result.paths.as_ref().unwrap();
1267 assert_eq!(paths.len(), 1, "expected 1 path from a→c");
1268 assert_eq!(paths[0].len(), 3);
1270 }
1271
1272 #[test]
1273 fn path_local_allows_shared_nodes_in_diamond() {
1274 let mut tg = TestGraph::new();
1276 let a = tg.add_node("da");
1277 let b = tg.add_node("db");
1278 let c = tg.add_node("dc");
1279 let d = tg.add_node("dd");
1280 tg.add_call_edge(a, b);
1281 tg.add_call_edge(a, c);
1282 tg.add_call_edge(b, d);
1283 tg.add_call_edge(c, d);
1284
1285 let snapshot = tg.snapshot();
1286 let config = TraversalConfig {
1287 direction: TraversalDirection::Outgoing,
1288 edge_filter: EdgeFilter::calls_only(),
1289 limits: TraversalLimits {
1290 max_depth: 5,
1291 max_nodes: None,
1292 max_edges: None,
1293 max_paths: Some(10),
1294 },
1295 };
1296
1297 let mut strategy = SimplePathStrategy::new(d, 0.0, true);
1298 let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1299
1300 let paths = result.paths.as_ref().unwrap();
1301 assert_eq!(paths.len(), 2, "expected 2 paths in diamond, got {paths:?}");
1303 }
1304
1305 #[test]
1306 fn path_enumeration_handles_cycles() {
1307 let mut tg = TestGraph::new();
1309 let a = tg.add_node("ca");
1310 let b = tg.add_node("cb");
1311 let c = tg.add_node("cc");
1312 tg.add_call_edge(a, b);
1313 tg.add_call_edge(b, c);
1314 tg.add_call_edge(c, a); let snapshot = tg.snapshot();
1317 let config = TraversalConfig {
1318 direction: TraversalDirection::Outgoing,
1319 edge_filter: EdgeFilter::calls_only(),
1320 limits: TraversalLimits {
1321 max_depth: 10,
1322 max_nodes: None,
1323 max_edges: None,
1324 max_paths: Some(10),
1325 },
1326 };
1327
1328 let mut strategy = SimplePathStrategy::new(c, 0.0, true);
1329 let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1330
1331 let paths = result.paths.as_ref().unwrap();
1332 assert_eq!(paths.len(), 1);
1334 assert_eq!(paths[0].len(), 3);
1335 }
1336
1337 #[test]
1338 fn path_enumeration_no_path() {
1339 let mut tg = TestGraph::new();
1341 let a = tg.add_node("na");
1342 let b = tg.add_node("nb");
1343 let c = tg.add_node("nc");
1344 tg.add_call_edge(a, b);
1345
1346 let snapshot = tg.snapshot();
1347 let config = TraversalConfig {
1348 direction: TraversalDirection::Outgoing,
1349 edge_filter: EdgeFilter::calls_only(),
1350 limits: TraversalLimits {
1351 max_depth: 5,
1352 max_nodes: None,
1353 max_edges: None,
1354 max_paths: Some(10),
1355 },
1356 };
1357
1358 let mut strategy = SimplePathStrategy::new(c, 0.0, true);
1359 let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1360
1361 let paths = result.paths.as_ref().unwrap();
1362 assert!(paths.is_empty(), "expected no paths, got {paths:?}");
1363 }
1364
1365 #[test]
1366 fn path_limit_truncation() {
1367 let mut tg = TestGraph::new();
1369 let a = tg.add_node("la");
1370 let b1 = tg.add_node("lb1");
1371 let b2 = tg.add_node("lb2");
1372 let b3 = tg.add_node("lb3");
1373 let c = tg.add_node("lc");
1374 tg.add_call_edge(a, b1);
1375 tg.add_call_edge(a, b2);
1376 tg.add_call_edge(a, b3);
1377 tg.add_call_edge(b1, c);
1378 tg.add_call_edge(b2, c);
1379 tg.add_call_edge(b3, c);
1380
1381 let snapshot = tg.snapshot();
1382 let config = TraversalConfig {
1383 direction: TraversalDirection::Outgoing,
1384 edge_filter: EdgeFilter::calls_only(),
1385 limits: TraversalLimits {
1386 max_depth: 5,
1387 max_nodes: None,
1388 max_edges: None,
1389 max_paths: Some(2),
1390 },
1391 };
1392
1393 let mut strategy = SimplePathStrategy::new(c, 0.0, true);
1394 let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1395
1396 let paths = result.paths.as_ref().unwrap();
1397 assert_eq!(paths.len(), 2, "expected exactly 2 paths (limited)");
1398 assert_eq!(
1399 result.metadata.truncation,
1400 Some(TruncationReason::PathLimit)
1401 );
1402 }
1403
1404 #[test]
1405 fn path_bfs_node_limit_truncation() {
1406 let mut tg = TestGraph::new();
1408 let a = tg.add_node("pna");
1409 let b = tg.add_node("pnb");
1410 let c = tg.add_node("pnc");
1411 let d = tg.add_node("pnd");
1412 tg.add_call_edge(a, b);
1413 tg.add_call_edge(b, c);
1414 tg.add_call_edge(c, d);
1415
1416 let snapshot = tg.snapshot();
1417 let config = TraversalConfig {
1418 direction: TraversalDirection::Outgoing,
1419 edge_filter: EdgeFilter::calls_only(),
1420 limits: TraversalLimits {
1421 max_depth: 10,
1422 max_nodes: Some(2),
1423 max_edges: None,
1424 max_paths: Some(10),
1425 },
1426 };
1427
1428 let mut strategy = SimplePathStrategy::new(d, 0.0, true);
1429 let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1430
1431 assert!(
1432 result.nodes.len() <= 2,
1433 "node limit violated: {} nodes",
1434 result.nodes.len()
1435 );
1436 assert_eq!(
1437 result.metadata.truncation,
1438 Some(TruncationReason::NodeLimit)
1439 );
1440 }
1441
1442 #[test]
1443 fn path_bfs_edge_limit_truncation() {
1444 let mut tg = TestGraph::new();
1446 let a = tg.add_node("pea");
1447 let b = tg.add_node("peb");
1448 let c = tg.add_node("pec");
1449 let d = tg.add_node("ped");
1450 tg.add_call_edge(a, b);
1451 tg.add_call_edge(b, c);
1452 tg.add_call_edge(c, d);
1453
1454 let snapshot = tg.snapshot();
1455 let config = TraversalConfig {
1456 direction: TraversalDirection::Outgoing,
1457 edge_filter: EdgeFilter::calls_only(),
1458 limits: TraversalLimits {
1459 max_depth: 10,
1460 max_nodes: None,
1461 max_edges: Some(1),
1462 max_paths: Some(10),
1463 },
1464 };
1465
1466 let mut strategy = SimplePathStrategy::new(d, 0.0, true);
1467 let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1468
1469 assert_eq!(
1470 result.metadata.truncation,
1471 Some(TruncationReason::EdgeLimit)
1472 );
1473 }
1474
1475 #[test]
1476 fn path_bfs_leaf_enumeration_no_target() {
1477 let mut tg = TestGraph::new();
1479 let a = tg.add_node("la");
1480 let b = tg.add_node("lb");
1481 let c = tg.add_node("lc");
1482 let d = tg.add_node("ld");
1483 tg.add_call_edge(a, b);
1484 tg.add_call_edge(b, c);
1485 tg.add_call_edge(a, d);
1486
1487 let snapshot = tg.snapshot();
1488 let config = TraversalConfig {
1489 direction: TraversalDirection::Outgoing,
1490 edge_filter: EdgeFilter::calls_only(),
1491 limits: TraversalLimits {
1492 max_depth: 10,
1493 max_nodes: None,
1494 max_edges: None,
1495 max_paths: Some(10),
1496 },
1497 };
1498
1499 let mut strategy = LeafPathStrategy;
1501 let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1502
1503 let paths = result.paths.as_ref().unwrap();
1504 assert!(
1506 paths.len() >= 2,
1507 "expected at least 2 leaf paths, got {}",
1508 paths.len()
1509 );
1510 }
1511
1512 struct LeafPathStrategy;
1514
1515 impl TraversalStrategy for LeafPathStrategy {
1516 fn frontier_mode(&self) -> FrontierMode {
1517 FrontierMode::PathEnumeration
1518 }
1519
1520 fn visited_policy(&self) -> VisitedPolicy {
1521 VisitedPolicy::PathLocal
1522 }
1523
1524 fn path_target(&self) -> Option<NodeId> {
1525 None
1526 }
1527 }
1528
1529 #[test]
1530 fn is_followable_edge_confidence_filtering() {
1531 let calls = EdgeKind::Calls {
1533 argument_count: 0,
1534 is_async: false,
1535 resolved_via: ResolvedVia::Direct,
1536 };
1537 assert!(is_followable_edge(&calls, 0.0));
1538 assert!(is_followable_edge(&calls, 1.0));
1539
1540 let ffi = EdgeKind::FfiCall {
1542 convention: crate::graph::unified::edge::kind::FfiConvention::C,
1543 };
1544 assert!(is_followable_edge(&ffi, 0.5));
1545 assert!(is_followable_edge(&ffi, 0.6));
1546 assert!(!is_followable_edge(&ffi, 0.7));
1547
1548 assert!(is_followable_edge(&EdgeKind::Defines, 0.3));
1550 assert!(!is_followable_edge(&EdgeKind::Defines, 0.4));
1551 }
1552}