Skip to main content

sqry_core/graph/unified/
kernel.rs

1//! Generic BFS kernel with `TraversalConfig` and strategy callbacks.
2//!
3//! Provides a single `traverse()` function that implements standard BFS (global
4//! visited set) and path-enumeration BFS (path-local cycle detection). All 14
5//! BFS implementations in sqry migrate to this kernel.
6
7use 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// ──────────────────── Configuration types ────────────────────
22
23/// Which direction to traverse edges.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum TraversalDirection {
26    /// Traverse outgoing edges (callees, dependencies).
27    Outgoing,
28    /// Traverse incoming edges (callers, reverse impact).
29    Incoming,
30    /// Traverse both directions (subgraph extraction).
31    Both,
32}
33
34/// Filter controlling which edge types to include during traversal.
35///
36/// Each boolean corresponds to an `EdgeClassification` variant group.
37/// Edges whose classification does not match any enabled flag are skipped.
38#[derive(Debug, Clone)]
39#[allow(clippy::struct_excessive_bools)] // One bool per edge classification variant is the natural API
40pub struct EdgeFilter {
41    /// Include `Call` edges.
42    pub include_calls: bool,
43    /// Include `Import` and `Export` edges.
44    pub include_imports: bool,
45    /// Include `Reference` edges.
46    pub include_references: bool,
47    /// Include `Inherits` and `Implements` edges.
48    pub include_inheritance: bool,
49    /// Include `Contains` and `Defines` edges.
50    pub include_structural: bool,
51    /// Include `TypeOf` edges.
52    pub include_type_edges: bool,
53    /// Include `DatabaseAccess` edges.
54    pub include_database: bool,
55    /// Include `ServiceInteraction` edges.
56    pub include_service: bool,
57}
58
59impl EdgeFilter {
60    /// All edge types included.
61    #[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    /// Only call edges.
76    #[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    /// Calls and imports.
91    #[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    /// Calls, imports, references, and inheritance (dependency impact).
106    #[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    /// Returns `true` if the given classification passes the filter.
121    #[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/// Resource limits for traversal.
141#[derive(Debug, Clone)]
142pub struct TraversalLimits {
143    /// Maximum BFS depth.
144    pub max_depth: u32,
145    /// Maximum number of nodes to collect.
146    pub max_nodes: Option<usize>,
147    /// Maximum number of edges to collect.
148    pub max_edges: Option<usize>,
149    /// Maximum number of paths to collect (path enumeration only).
150    pub max_paths: Option<usize>,
151}
152
153impl TraversalLimits {
154    /// Default limits for subgraph extraction.
155    #[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    /// Default limits for graph export.
166    #[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    /// Default limits for dependency impact analysis.
177    #[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    /// Default limits for path tracing.
188    #[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/// Complete traversal configuration.
200#[derive(Debug, Clone)]
201pub struct TraversalConfig {
202    /// Which direction to traverse edges.
203    pub direction: TraversalDirection,
204    /// Which edge types to include/traverse.
205    pub edge_filter: EdgeFilter,
206    /// Resource limits.
207    pub limits: TraversalLimits,
208}
209
210// ──────────────────── Strategy trait ────────────────────
211
212/// What the BFS frontier queue stores.
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum FrontierMode {
215    /// Queue stores `(NodeId, depth)`. Used by standard BFS consumers.
216    Standard,
217    /// Queue stores `(NodeId, Vec<NodeId> path, depth)`. Used by `trace_path`.
218    PathEnumeration,
219}
220
221/// How dedup/cycle-detection works.
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum VisitedPolicy {
224    /// Global `HashSet<NodeId>`. Never re-enqueue visited nodes.
225    Global,
226    /// No global visited set. Cycle check against in-flight path only.
227    /// Allows same node in multiple alternate paths.
228    PathLocal,
229}
230
231/// Strategy callbacks for customizing traversal behavior.
232///
233/// Default implementations produce standard BFS with global visited set.
234pub trait TraversalStrategy {
235    /// Filter edges before processing. Access raw edge for confidence/followability.
236    fn accept_edge(&mut self, _edge: &StoreEdgeRef, _depth: u32) -> bool {
237        true
238    }
239
240    /// Control enqueueing with full context. Supports SCC pruning.
241    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    /// Called when a path reaches target (`PathEnumeration` mode only).
252    /// Return `Break` to stop collecting paths.
253    fn on_path_complete(&mut self, _path: &[NodeId]) -> ControlFlow<()> {
254        ControlFlow::Continue(())
255    }
256
257    /// What the queue stores.
258    fn frontier_mode(&self) -> FrontierMode {
259        FrontierMode::Standard
260    }
261
262    /// How dedup/cycle-detection works.
263    fn visited_policy(&self) -> VisitedPolicy {
264        VisitedPolicy::Global
265    }
266
267    /// Path target for `PathEnumeration` mode. `None` = enumerate all paths to leaves.
268    fn path_target(&self) -> Option<NodeId> {
269        None
270    }
271}
272
273/// Default no-op strategy for standard BFS.
274struct DefaultStrategy;
275impl TraversalStrategy for DefaultStrategy {}
276
277// ──────────────────── Edge followability ────────────────────
278
279/// Returns `true` if an edge kind should be followed during path traversal
280/// at the given minimum confidence threshold.
281///
282/// Confidence mapping:
283/// - `Calls`, `TraitMethodBinding`: 1.0 (highest confidence)
284/// - `Inherits`, `Implements`: 0.9
285/// - `Imports`, `Exports`: 0.8
286/// - `References`: 0.7
287/// - Cross-boundary calls (`FfiCall`, `HttpRequest`, `GrpcCall`, `WebAssemblyCall`): 0.6
288/// - `MacroExpansion`: 0.5
289/// - Everything else: 0.3
290#[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
307// ──────────────────── Built-in strategies ────────────────────
308
309/// Simple path enumeration strategy for `trace_path` (non-optimized variant).
310///
311/// Traverses all paths from seeds to `target`, filtering edges by minimum
312/// confidence and optionally restricting to same-language edges.
313pub struct SimplePathStrategy {
314    /// Target node to find paths to.
315    target: NodeId,
316    /// Minimum confidence threshold for edge followability.
317    min_confidence: f64,
318    /// Whether to allow cross-language edges.
319    allow_cross_language: bool,
320}
321
322impl SimplePathStrategy {
323    /// Creates a new simple path strategy.
324    #[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
353/// SCC-pruned path enumeration strategy for `trace_path` (optimized variant).
354///
355/// Uses precomputed SCC data and condensation DAG to prune branches that
356/// cannot reach the target node's SCC component.
357pub struct SccPathStrategy<'a> {
358    /// Precomputed SCC data.
359    scc_data: &'a super::analysis::scc::SccData,
360    /// Precomputed condensation DAG for reachability queries.
361    cond_dag: &'a super::analysis::condensation::CondensationDag,
362    /// Target node.
363    target: NodeId,
364    /// SCC index of the target node (cached).
365    target_scc: Option<u32>,
366    /// Minimum confidence threshold for edge followability.
367    min_confidence: f64,
368    /// Whether to allow cross-language edges.
369    allow_cross_language: bool,
370}
371
372impl<'a> SccPathStrategy<'a> {
373    /// Creates a new SCC-pruned path strategy.
374    #[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        // If target SCC is unknown, allow all (conservative)
408        let Some(target_scc) = self.target_scc else {
409            return true;
410        };
411
412        // If node's SCC is unknown, allow (conservative)
413        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// ──────────────────── Public API ────────────────────
438
439/// Execute a graph traversal from the given seeds.
440///
441/// With `strategy: None`, performs standard BFS with global visited set.
442/// With a strategy, behavior is customized via the trait methods.
443#[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            // Standard + PathLocal is invalid, treat as Standard + Global
459            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            // Unusual but supported: path enumeration with global visited
466            run_path_bfs(snapshot, seeds, config, strategy)
467        }
468    }
469}
470
471// ──────────────────── Standard BFS ────────────────────
472
473/// Raw edge tuple collected during BFS.
474struct RawEdge {
475    source: NodeId,
476    target: NodeId,
477    kind: EdgeKind,
478    depth: u32,
479}
480
481/// Standard BFS with global visited set.
482fn 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    // Seed the queue
497    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        // Check node limit
513        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            // Check edge limit
524            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            // Determine the "next" node depending on direction
541            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                // Enforce node limit before adding
554                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// ──────────────────── Path enumeration BFS (stub for Task 4) ────────────────────
579
580/// Path enumeration BFS with path-local cycle detection.
581#[allow(clippy::too_many_lines)] // Kernel query dispatches across all edge kinds; complex path-tracking BFS state machine
582fn 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    // Queue: (current_node, path_so_far, depth)
598    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        // Check if we reached the target
618        if let Some(t) = target
619            && current == t
620            && path.len() > 1
621        {
622            // Record nodes along the path in discovery order
623            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            // Check path limit
633            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        // Check node limit
652        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        // If no target specified, leaf nodes (no followable outgoing edges) complete a path
662        let mut has_followable_successor = false;
663
664        for edge_ref in &edges {
665            // Check edge limit
666            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            // Cycle detection: path-local or global
685            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            // Enforce node limit before adding
707            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        // Leaf enumeration: if no target specified and node is a leaf, record path
724        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
761// ──────────────────── Shared helpers ────────────────────
762
763/// Collect edges for a node in the given direction.
764fn 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
780/// Given an edge and the current node, return the neighbor to traverse to.
781fn 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/// Materialize collected BFS results into a `TraversalResult`.
790///
791/// Converts discovered `NodeId`s into `MaterializedNode`s, builds the index map,
792/// converts raw edges to `MaterializedEdge`s, and optionally converts paths.
793#[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    // Materialize nodes and build index map
805    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    // Convert raw edges to MaterializedEdge, dropping any with missing indices
820    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    // Deduplicate edges (same source_idx, target_idx, classification)
836    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    // Convert paths
843    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                // Drop paths where any node couldn't be materialized
852                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    /// Helper to create a test graph with nodes and edges.
887    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        // First node should be the seed
993        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        // Depth 1: should discover a and b, but not c (b→c is at depth 2)
1009        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        // b's incoming edges are from a and c
1081        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        // Start from b — should find a (incoming) and c (outgoing)
1106        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        // calls_only should not include the import edge
1129        let result = traverse(&snapshot, &[a], &calls_config(3), None);
1130        assert_eq!(result.nodes.len(), 2); // a and b only
1131
1132        // calls_and_imports should include both
1133        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); // a, b, and c
1145    }
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        // Global visited set prevents infinite loop
1159        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        // Every edge index must be < nodes.len()
1205        for edge in &result.edges {
1206            assert!(edge.source_idx < result.nodes.len());
1207            assert!(edge.target_idx < result.nodes.len());
1208        }
1209
1210        // Metadata is consistent
1211        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    // ──────────────────── Path enumeration tests ────────────────────
1240
1241    #[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        // Path should have 3 nodes: a → b → c
1269        assert_eq!(paths[0].len(), 3);
1270    }
1271
1272    #[test]
1273    fn path_local_allows_shared_nodes_in_diamond() {
1274        // Diamond graph: a→b, a→c, b→d, c→d
1275        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        // Two paths: a→b→d and a→c→d
1302        assert_eq!(paths.len(), 2, "expected 2 paths in diamond, got {paths:?}");
1303    }
1304
1305    #[test]
1306    fn path_enumeration_handles_cycles() {
1307        // a→b→c→a (cycle), looking for path to c
1308        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); // cycle back
1315
1316        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        // Should find a→b→c without infinite loop
1333        assert_eq!(paths.len(), 1);
1334        assert_eq!(paths[0].len(), 3);
1335    }
1336
1337    #[test]
1338    fn path_enumeration_no_path() {
1339        // a→b, c is disconnected, looking for path from a to c
1340        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        // Create a graph with many paths: a→b1, a→b2, a→b3, b1→c, b2→c, b3→c
1368        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        // a→b→c→d, path to d, but max_nodes=2
1407        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        // a→b→c→d, path to d, but max_edges=1
1445        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        // a→b→c (leaf), a→d (leaf) — enumerate paths to all leaves
1478        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        // No target — should enumerate paths to leaves
1500        let mut strategy = LeafPathStrategy;
1501        let result = traverse(&snapshot, &[a], &config, Some(&mut strategy));
1502
1503        let paths = result.paths.as_ref().unwrap();
1504        // Two leaf paths: a→b→c and a→d
1505        assert!(
1506            paths.len() >= 2,
1507            "expected at least 2 leaf paths, got {}",
1508            paths.len()
1509        );
1510    }
1511
1512    /// Strategy that enumerates paths to all leaves (no specific target).
1513    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        // High confidence edges pass any threshold
1532        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        // FFI call has 0.6 confidence
1541        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        // Defines has 0.3 confidence
1549        assert!(is_followable_edge(&EdgeKind::Defines, 0.3));
1550        assert!(!is_followable_edge(&EdgeKind::Defines, 0.4));
1551    }
1552}