Skip to main content

sqry_core/graph/unified/node/
cascade.rs

1//! Cascade cleanup for node removal.
2//!
3//! This module implements cascade cleanup operations that ensure all graph
4//! data structures remain consistent when a node is removed.
5//!
6//! # Design
7//!
8//! When a node is removed, the following must be cleaned up:
9//! - **`NodeArena`**: Remove the node entry
10//! - **`AuxiliaryIndices`**: Remove from kind, name, and file indices
11//! - **`BidirectionalEdgeStore`**: Invalidate all edges to/from the node
12//!
13//! # Example
14//!
15//! ```rust,ignore
16//! use sqry_core::graph::unified::node::cascade::CascadeCleanup;
17//!
18//! let mut cleanup = CascadeCleanup::new();
19//!
20//! // Remove a single node
21//! let result = cleanup.remove_node(&mut arena, &mut indices, &edge_store, node_id);
22//!
23//! // Remove all nodes in a file (for incremental re-indexing)
24//! let results = cleanup.remove_file(&mut arena, &mut indices, &edge_store, file_id);
25//! ```
26
27use crate::graph::unified::edge::bidirectional::BidirectionalEdgeStore;
28use crate::graph::unified::file::FileId;
29use crate::graph::unified::node::id::NodeId;
30use crate::graph::unified::node::kind::NodeKind;
31use crate::graph::unified::storage::arena::{NodeArena, NodeEntry};
32use crate::graph::unified::storage::indices::AuxiliaryIndices;
33use crate::graph::unified::string::StringId;
34
35/// Result of a cascade node removal operation.
36#[derive(Debug, Clone)]
37pub struct CascadeRemovalResult {
38    /// The node ID that was removed.
39    pub node_id: NodeId,
40    /// The node entry that was removed, if it existed.
41    pub entry: Option<NodeEntry>,
42    /// Number of edges invalidated (add tombstones) for this node.
43    pub edges_invalidated: usize,
44    /// Whether the node was found in auxiliary indices.
45    pub removed_from_indices: bool,
46}
47
48impl CascadeRemovalResult {
49    /// Returns true if the node was actually removed (existed before).
50    #[inline]
51    #[must_use]
52    pub fn was_removed(&self) -> bool {
53        self.entry.is_some()
54    }
55}
56
57/// Summary of a file-level cascade removal.
58#[derive(Debug, Clone)]
59pub struct FileCascadeResult {
60    /// The file that was cleared.
61    pub file: FileId,
62    /// Number of nodes removed from the arena.
63    pub nodes_removed: usize,
64    /// Total number of edges invalidated.
65    pub edges_invalidated: usize,
66}
67
68/// Cascade cleanup coordinator.
69///
70/// Provides methods to cleanly remove nodes from all graph data structures,
71/// ensuring no dangling references remain.
72///
73/// # Invariants
74///
75/// After a cascade removal:
76/// - The node is no longer in `NodeArena`
77/// - The node is no longer in `AuxiliaryIndices`
78/// - All edges to/from the node are tombstoned in `BidirectionalEdgeStore`
79#[derive(Debug, Default)]
80pub struct CascadeCleanup {
81    /// Optional statistics tracking.
82    stats: CascadeStats,
83}
84
85/// Statistics for cascade cleanup operations.
86#[derive(Debug, Clone, Copy, Default)]
87pub struct CascadeStats {
88    /// Total nodes removed.
89    pub nodes_removed: u64,
90    /// Total edges invalidated.
91    pub edges_invalidated: u64,
92    /// Total files processed.
93    pub files_processed: u64,
94}
95
96impl CascadeCleanup {
97    /// Creates a new cascade cleanup coordinator.
98    #[must_use]
99    pub fn new() -> Self {
100        Self {
101            stats: CascadeStats::default(),
102        }
103    }
104
105    /// Returns the accumulated statistics.
106    #[must_use]
107    pub fn stats(&self) -> CascadeStats {
108        self.stats
109    }
110
111    /// Resets the statistics counters.
112    pub fn reset_stats(&mut self) {
113        self.stats = CascadeStats::default();
114    }
115
116    /// Removes a single node and all associated data.
117    ///
118    /// # Operations Performed
119    ///
120    /// 1. Removes node from `NodeArena` (returns entry if existed)
121    /// 2. Removes node from `AuxiliaryIndices` (if metadata available)
122    /// 3. Tombstones all edges to/from the node in `BidirectionalEdgeStore`
123    ///
124    /// # Arguments
125    ///
126    /// * `arena` - The node arena to remove from
127    /// * `indices` - The auxiliary indices to update
128    /// * `edge_store` - The edge store to tombstone edges in
129    /// * `node_id` - The ID of the node to remove
130    ///
131    /// # Returns
132    ///
133    /// Details about what was removed.
134    pub fn remove_node(
135        &mut self,
136        arena: &mut NodeArena,
137        indices: &mut AuxiliaryIndices,
138        edge_store: &BidirectionalEdgeStore,
139        node_id: NodeId,
140    ) -> CascadeRemovalResult {
141        // Step 1: Remove from arena (get metadata for index removal)
142        let entry = arena.remove(node_id);
143
144        // Step 2: Remove node from all indices using the entry we just removed
145        let removed_from_indices = if let Some(ref e) = entry {
146            indices.remove(node_id, e.kind, e.name, e.qualified_name, e.file)
147        } else {
148            false
149        };
150
151        // Step 3: Invalidate edges to/from this node
152        let edges_invalidated = Self::invalidate_node_edges(edge_store, node_id, entry.as_ref());
153
154        // Update stats
155        if entry.is_some() {
156            self.stats.nodes_removed += 1;
157        }
158        self.stats.edges_invalidated += edges_invalidated as u64;
159
160        CascadeRemovalResult {
161            node_id,
162            entry,
163            edges_invalidated,
164            removed_from_indices,
165        }
166    }
167
168    /// Removes a node using pre-known metadata.
169    ///
170    /// This is more efficient when the node metadata is already known
171    /// (e.g., from iteration), avoiding an extra lookup.
172    ///
173    /// # Arguments
174    ///
175    /// * `arena` - The node arena to remove from
176    /// * `indices` - The auxiliary indices to update
177    /// * `edge_store` - The edge store to tombstone edges in
178    /// * `node_id` - The ID of the node to remove
179    /// * `kind` - The known kind of the node
180    /// * `name` - The known name of the node
181    /// * `file` - The known file of the node
182    ///
183    /// # Returns
184    ///
185    /// Details about what was removed.
186    #[allow(clippy::too_many_arguments)]
187    pub fn remove_node_with_metadata(
188        &mut self,
189        arena: &mut NodeArena,
190        indices: &mut AuxiliaryIndices,
191        edge_store: &BidirectionalEdgeStore,
192        node_id: NodeId,
193        kind: NodeKind,
194        name: StringId,
195        qualified_name: Option<StringId>,
196        file: FileId,
197    ) -> CascadeRemovalResult {
198        // Step 1: Remove from arena
199        let entry = arena.remove(node_id);
200
201        // Step 2: Remove from indices using provided metadata
202        let removed_from_indices = indices.remove(node_id, kind, name, qualified_name, file);
203
204        // Step 3: Invalidate edges
205        let edges_invalidated = Self::invalidate_node_edges(edge_store, node_id, entry.as_ref());
206
207        // Update stats
208        if entry.is_some() {
209            self.stats.nodes_removed += 1;
210        }
211        self.stats.edges_invalidated += edges_invalidated as u64;
212
213        CascadeRemovalResult {
214            node_id,
215            entry,
216            edges_invalidated,
217            removed_from_indices,
218        }
219    }
220
221    /// Removes all nodes in a file.
222    ///
223    /// This is the efficient path for file deletion during incremental updates.
224    /// It collects all nodes in the file first, then performs bulk cleanup.
225    ///
226    /// # Arguments
227    ///
228    /// * `arena` - The node arena to remove from
229    /// * `indices` - The auxiliary indices to update
230    /// * `edge_store` - The edge store to tombstone edges in
231    /// * `file` - The file ID to remove all nodes from
232    ///
233    /// # Returns
234    ///
235    /// Summary of the file removal operation.
236    pub fn remove_file(
237        &mut self,
238        arena: &mut NodeArena,
239        indices: &mut AuxiliaryIndices,
240        edge_store: &BidirectionalEdgeStore,
241        file: FileId,
242    ) -> FileCascadeResult {
243        // Collect nodes in this file from indices (fast O(1) lookup)
244        let node_ids: Vec<NodeId> = indices.by_file(file).to_vec();
245
246        // Collect metadata before removal (need to access arena)
247        let nodes_metadata: Vec<_> = node_ids
248            .iter()
249            .filter_map(|&id| {
250                arena
251                    .get(id)
252                    .map(|e| (id, e.kind, e.name, e.qualified_name))
253            })
254            .collect();
255
256        // Step 1: Remove from indices using efficient bulk method
257        let _indices_removed = indices.remove_file_with_info(file, nodes_metadata.iter().copied());
258
259        // Step 2: Remove each node from arena and invalidate edges
260        let mut nodes_removed = 0;
261        let mut edges_invalidated = 0;
262
263        for &node_id in &node_ids {
264            // Get entry before removal for edge invalidation
265            let entry = arena.remove(node_id);
266
267            if entry.is_some() {
268                nodes_removed += 1;
269            }
270
271            // Invalidate edges
272            edges_invalidated += Self::invalidate_node_edges(edge_store, node_id, entry.as_ref());
273        }
274
275        // Also clear any file-associated edges that might remain
276        let file_edges_cleared = edge_store.clear_file(file);
277        edges_invalidated += file_edges_cleared;
278
279        // Update stats
280        self.stats.nodes_removed += nodes_removed as u64;
281        self.stats.edges_invalidated += edges_invalidated as u64;
282        self.stats.files_processed += 1;
283
284        FileCascadeResult {
285            file,
286            nodes_removed,
287            edges_invalidated,
288        }
289    }
290
291    /// Invalidates all edges to/from a node by adding tombstones.
292    ///
293    /// This ensures that even if the edges are in the CSR, they will be
294    /// filtered out during queries.
295    fn invalidate_node_edges(
296        edge_store: &BidirectionalEdgeStore,
297        node_id: NodeId,
298        entry: Option<&NodeEntry>,
299    ) -> usize {
300        // Get the file ID for tombstone creation
301        let file = entry.map_or(FileId::new(0), |e| e.file);
302
303        let mut count = 0;
304
305        // Get outgoing edges and tombstone them
306        let outgoing = edge_store.edges_from(node_id);
307        for edge in outgoing {
308            edge_store.remove_edge(node_id, edge.target, edge.kind.clone(), file);
309            count += 1;
310        }
311
312        // Get incoming edges and tombstone them
313        // edge.source now preserves full NodeId with generation for correct EdgeKey matching
314        // edge.file preserves the original source file for correct Remove delta partitioning
315        let incoming = edge_store.edges_to(node_id);
316        for edge in incoming {
317            // Use edge.file (source's file) so Remove delta is partitioned with the original Add
318            edge_store.remove_edge(edge.source, node_id, edge.kind.clone(), edge.file);
319            count += 1;
320        }
321
322        count
323    }
324}
325
326/// Convenience function for single node removal.
327///
328/// This is a standalone function for simple use cases where you don't
329/// need statistics tracking.
330pub fn cascade_remove_node(
331    arena: &mut NodeArena,
332    indices: &mut AuxiliaryIndices,
333    edge_store: &BidirectionalEdgeStore,
334    node_id: NodeId,
335) -> CascadeRemovalResult {
336    let mut cleanup = CascadeCleanup::new();
337    cleanup.remove_node(arena, indices, edge_store, node_id)
338}
339
340/// Convenience function for file removal.
341///
342/// This is a standalone function for simple use cases where you don't
343/// need statistics tracking.
344pub fn cascade_remove_file(
345    arena: &mut NodeArena,
346    indices: &mut AuxiliaryIndices,
347    edge_store: &BidirectionalEdgeStore,
348    file: FileId,
349) -> FileCascadeResult {
350    let mut cleanup = CascadeCleanup::new();
351    cleanup.remove_file(arena, indices, edge_store, file)
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::graph::unified::edge::kind::{EdgeKind, ResolvedVia};
358
359    fn test_file(id: u32) -> FileId {
360        FileId::new(id)
361    }
362
363    fn test_name(id: u32) -> StringId {
364        StringId::new(id)
365    }
366
367    fn test_entry(kind: NodeKind, name: StringId, file: FileId) -> NodeEntry {
368        NodeEntry::new(kind, name, file)
369    }
370
371    #[test]
372    fn test_cascade_cleanup_new() {
373        let cleanup = CascadeCleanup::new();
374        assert_eq!(cleanup.stats().nodes_removed, 0);
375        assert_eq!(cleanup.stats().edges_invalidated, 0);
376    }
377
378    #[test]
379    fn test_remove_single_node() {
380        let mut arena = NodeArena::new();
381        let mut indices = AuxiliaryIndices::new();
382        let edge_store = BidirectionalEdgeStore::new();
383
384        let file = test_file(1);
385        let name = test_name(1);
386        let entry = test_entry(NodeKind::Function, name, file);
387
388        let node_id = arena.alloc(entry).unwrap();
389        indices.add(node_id, NodeKind::Function, name, None, file);
390
391        assert!(arena.contains(node_id));
392        assert_eq!(indices.len(), 1);
393
394        let mut cleanup = CascadeCleanup::new();
395        let result = cleanup.remove_node(&mut arena, &mut indices, &edge_store, node_id);
396
397        assert!(result.was_removed());
398        assert!(!arena.contains(node_id));
399        assert_eq!(indices.len(), 0);
400        assert_eq!(cleanup.stats().nodes_removed, 1);
401    }
402
403    #[test]
404    fn test_remove_nonexistent_node() {
405        let mut arena = NodeArena::new();
406        let mut indices = AuxiliaryIndices::new();
407        let edge_store = BidirectionalEdgeStore::new();
408
409        let fake_id = NodeId::new(999, 1);
410
411        let mut cleanup = CascadeCleanup::new();
412        let result = cleanup.remove_node(&mut arena, &mut indices, &edge_store, fake_id);
413
414        assert!(!result.was_removed());
415        assert_eq!(result.entry, None);
416        assert!(!result.removed_from_indices);
417        assert_eq!(cleanup.stats().nodes_removed, 0);
418    }
419
420    #[test]
421    fn test_remove_node_with_edges() {
422        let mut arena = NodeArena::new();
423        let mut indices = AuxiliaryIndices::new();
424        let edge_store = BidirectionalEdgeStore::new();
425
426        let file = test_file(1);
427
428        // Create three nodes
429        let entry1 = test_entry(NodeKind::Function, test_name(1), file);
430        let entry2 = test_entry(NodeKind::Function, test_name(2), file);
431        let entry3 = test_entry(NodeKind::Function, test_name(3), file);
432
433        let id1 = arena.alloc(entry1).unwrap();
434        let id2 = arena.alloc(entry2).unwrap();
435        let id3 = arena.alloc(entry3).unwrap();
436
437        indices.add(id1, NodeKind::Function, test_name(1), None, file);
438        indices.add(id2, NodeKind::Function, test_name(2), None, file);
439        indices.add(id3, NodeKind::Function, test_name(3), None, file);
440
441        // Create edges: id1 -> id2, id3 -> id1
442        edge_store.add_edge(
443            id1,
444            id2,
445            EdgeKind::Calls {
446                argument_count: 0,
447                is_async: false,
448                resolved_via: ResolvedVia::Direct,
449            },
450            file,
451        );
452        edge_store.add_edge(
453            id3,
454            id1,
455            EdgeKind::Calls {
456                argument_count: 0,
457                is_async: false,
458                resolved_via: ResolvedVia::Direct,
459            },
460            file,
461        );
462
463        // Verify edges exist
464        assert_eq!(edge_store.edges_from(id1).len(), 1);
465        assert_eq!(edge_store.edges_to(id1).len(), 1);
466
467        // Remove id1
468        let mut cleanup = CascadeCleanup::new();
469        let result = cleanup.remove_node(&mut arena, &mut indices, &edge_store, id1);
470
471        assert!(result.was_removed());
472        assert_eq!(result.edges_invalidated, 2); // one outgoing, one incoming
473
474        // Node should be gone
475        assert!(!arena.contains(id1));
476        assert_eq!(indices.len(), 2);
477
478        // Stats should reflect the removal
479        assert_eq!(cleanup.stats().nodes_removed, 1);
480        assert_eq!(cleanup.stats().edges_invalidated, 2);
481    }
482
483    #[test]
484    fn test_remove_node_with_metadata() {
485        let mut arena = NodeArena::new();
486        let mut indices = AuxiliaryIndices::new();
487        let edge_store = BidirectionalEdgeStore::new();
488
489        let file = test_file(1);
490        let name = test_name(1);
491        let entry = test_entry(NodeKind::Function, name, file);
492
493        let node_id = arena.alloc(entry).unwrap();
494        indices.add(node_id, NodeKind::Function, name, None, file);
495
496        let mut cleanup = CascadeCleanup::new();
497        let result = cleanup.remove_node_with_metadata(
498            &mut arena,
499            &mut indices,
500            &edge_store,
501            node_id,
502            NodeKind::Function,
503            name,
504            None,
505            file,
506        );
507
508        assert!(result.was_removed());
509        assert!(result.removed_from_indices);
510        assert!(!arena.contains(node_id));
511        assert_eq!(indices.len(), 0);
512    }
513
514    #[test]
515    fn test_remove_file() {
516        let mut arena = NodeArena::new();
517        let mut indices = AuxiliaryIndices::new();
518        let edge_store = BidirectionalEdgeStore::new();
519
520        let file1 = test_file(1);
521        let file2 = test_file(2);
522
523        // Create nodes in file1
524        let entry1 = test_entry(NodeKind::Function, test_name(1), file1);
525        let entry2 = test_entry(NodeKind::Class, test_name(2), file1);
526        let entry3 = test_entry(NodeKind::Method, test_name(3), file1);
527
528        // Create node in file2
529        let entry4 = test_entry(NodeKind::Function, test_name(4), file2);
530
531        let id1 = arena.alloc(entry1).unwrap();
532        let id2 = arena.alloc(entry2).unwrap();
533        let id3 = arena.alloc(entry3).unwrap();
534        let id4 = arena.alloc(entry4).unwrap();
535
536        indices.add(id1, NodeKind::Function, test_name(1), None, file1);
537        indices.add(id2, NodeKind::Class, test_name(2), None, file1);
538        indices.add(id3, NodeKind::Method, test_name(3), None, file1);
539        indices.add(id4, NodeKind::Function, test_name(4), None, file2);
540
541        // Add edges within file1
542        edge_store.add_edge(
543            id1,
544            id2,
545            EdgeKind::Calls {
546                argument_count: 0,
547                is_async: false,
548                resolved_via: ResolvedVia::Direct,
549            },
550            file1,
551        );
552        edge_store.add_edge(
553            id2,
554            id3,
555            EdgeKind::Calls {
556                argument_count: 0,
557                is_async: false,
558                resolved_via: ResolvedVia::Direct,
559            },
560            file1,
561        );
562
563        assert_eq!(arena.len(), 4);
564        assert_eq!(indices.len(), 4);
565
566        // Remove file1
567        let mut cleanup = CascadeCleanup::new();
568        let result = cleanup.remove_file(&mut arena, &mut indices, &edge_store, file1);
569
570        assert_eq!(result.file, file1);
571        assert_eq!(result.nodes_removed, 3);
572
573        // Verify file1 nodes are gone
574        assert!(!arena.contains(id1));
575        assert!(!arena.contains(id2));
576        assert!(!arena.contains(id3));
577
578        // Verify file2 node remains
579        assert!(arena.contains(id4));
580
581        // Verify indices updated
582        assert_eq!(indices.len(), 1);
583        assert!(indices.by_file(file1).is_empty());
584        assert_eq!(indices.by_file(file2).len(), 1);
585
586        // Verify stats
587        assert_eq!(cleanup.stats().nodes_removed, 3);
588        assert_eq!(cleanup.stats().files_processed, 1);
589    }
590
591    #[test]
592    fn test_remove_empty_file() {
593        let mut arena = NodeArena::new();
594        let mut indices = AuxiliaryIndices::new();
595        let edge_store = BidirectionalEdgeStore::new();
596
597        let file = test_file(1);
598
599        let mut cleanup = CascadeCleanup::new();
600        let result = cleanup.remove_file(&mut arena, &mut indices, &edge_store, file);
601
602        assert_eq!(result.nodes_removed, 0);
603        assert_eq!(result.edges_invalidated, 0);
604    }
605
606    #[test]
607    fn test_convenience_cascade_remove_node() {
608        let mut arena = NodeArena::new();
609        let mut indices = AuxiliaryIndices::new();
610        let edge_store = BidirectionalEdgeStore::new();
611
612        let file = test_file(1);
613        let name = test_name(1);
614        let entry = test_entry(NodeKind::Function, name, file);
615
616        let node_id = arena.alloc(entry).unwrap();
617        indices.add(node_id, NodeKind::Function, name, None, file);
618
619        let result = cascade_remove_node(&mut arena, &mut indices, &edge_store, node_id);
620
621        assert!(result.was_removed());
622        assert!(!arena.contains(node_id));
623    }
624
625    #[test]
626    fn test_convenience_cascade_remove_file() {
627        let mut arena = NodeArena::new();
628        let mut indices = AuxiliaryIndices::new();
629        let edge_store = BidirectionalEdgeStore::new();
630
631        let file = test_file(1);
632
633        let entry = test_entry(NodeKind::Function, test_name(1), file);
634        let node_id = arena.alloc(entry).unwrap();
635        indices.add(node_id, NodeKind::Function, test_name(1), None, file);
636
637        let result = cascade_remove_file(&mut arena, &mut indices, &edge_store, file);
638
639        assert_eq!(result.nodes_removed, 1);
640        assert!(!arena.contains(node_id));
641    }
642
643    #[test]
644    fn test_reset_stats() {
645        let mut cleanup = CascadeCleanup::new();
646        let mut arena = NodeArena::new();
647        let mut indices = AuxiliaryIndices::new();
648        let edge_store = BidirectionalEdgeStore::new();
649
650        let file = test_file(1);
651        let entry = test_entry(NodeKind::Function, test_name(1), file);
652        let node_id = arena.alloc(entry).unwrap();
653        indices.add(node_id, NodeKind::Function, test_name(1), None, file);
654
655        cleanup.remove_node(&mut arena, &mut indices, &edge_store, node_id);
656
657        assert_eq!(cleanup.stats().nodes_removed, 1);
658
659        cleanup.reset_stats();
660
661        assert_eq!(cleanup.stats().nodes_removed, 0);
662        assert_eq!(cleanup.stats().edges_invalidated, 0);
663    }
664
665    #[test]
666    fn test_cascade_removal_result_methods() {
667        let result_with_entry = CascadeRemovalResult {
668            node_id: NodeId::new(1, 1),
669            entry: Some(NodeEntry::new(
670                NodeKind::Function,
671                StringId::new(1),
672                FileId::new(1),
673            )),
674            edges_invalidated: 5,
675            removed_from_indices: true,
676        };
677        assert!(result_with_entry.was_removed());
678
679        let result_without_entry = CascadeRemovalResult {
680            node_id: NodeId::new(2, 1),
681            entry: None,
682            edges_invalidated: 0,
683            removed_from_indices: false,
684        };
685        assert!(!result_without_entry.was_removed());
686    }
687
688    #[test]
689    fn test_no_dangling_edges_after_removal() {
690        // Comprehensive test for No dangling edges after node removal
691        let mut arena = NodeArena::new();
692        let mut indices = AuxiliaryIndices::new();
693        let edge_store = BidirectionalEdgeStore::new();
694
695        let file = test_file(1);
696
697        // Create a graph: A -> B -> C, A -> C
698        let entry_a = test_entry(NodeKind::Function, test_name(1), file);
699        let entry_b = test_entry(NodeKind::Function, test_name(2), file);
700        let entry_c = test_entry(NodeKind::Function, test_name(3), file);
701
702        let id_a = arena.alloc(entry_a).unwrap();
703        let id_b = arena.alloc(entry_b).unwrap();
704        let id_c = arena.alloc(entry_c).unwrap();
705
706        indices.add(id_a, NodeKind::Function, test_name(1), None, file);
707        indices.add(id_b, NodeKind::Function, test_name(2), None, file);
708        indices.add(id_c, NodeKind::Function, test_name(3), None, file);
709
710        edge_store.add_edge(
711            id_a,
712            id_b,
713            EdgeKind::Calls {
714                argument_count: 0,
715                is_async: false,
716                resolved_via: ResolvedVia::Direct,
717            },
718            file,
719        );
720        edge_store.add_edge(
721            id_b,
722            id_c,
723            EdgeKind::Calls {
724                argument_count: 0,
725                is_async: false,
726                resolved_via: ResolvedVia::Direct,
727            },
728            file,
729        );
730        edge_store.add_edge(
731            id_a,
732            id_c,
733            EdgeKind::Calls {
734                argument_count: 0,
735                is_async: false,
736                resolved_via: ResolvedVia::Direct,
737            },
738            file,
739        );
740
741        // Remove node B (middle of chain)
742        let mut cleanup = CascadeCleanup::new();
743        let result = cleanup.remove_node(&mut arena, &mut indices, &edge_store, id_b);
744
745        // Verify B was removed
746        assert!(result.was_removed());
747        assert!(!arena.contains(id_b));
748
749        // Verify A and C still exist
750        assert!(arena.contains(id_a));
751        assert!(arena.contains(id_c));
752
753        // Verify edges from/to B have tombstones (will be filtered in queries)
754        // After removal, querying edges from B should show removed edges have tombstones
755        assert_eq!(result.edges_invalidated, 2); // A->B and B->C
756
757        // CRITICAL: Assert edge visibility post-removal
758        // Edges from A should no longer include B (only A->C should remain visible)
759        let edges_from_a = edge_store.edges_from(id_a);
760        assert_eq!(
761            edges_from_a.len(),
762            1,
763            "A should have exactly 1 visible edge (A->C)"
764        );
765        assert_eq!(
766            edges_from_a[0].target, id_c,
767            "A's edge should point to C, not B"
768        );
769
770        // Edges to B should be empty (A->B has been tombstoned)
771        let edges_to_b = edge_store.edges_to(id_b);
772        assert!(
773            edges_to_b.is_empty(),
774            "No edges should be visible to removed node B"
775        );
776
777        // Edges from B should be empty (B->C has been tombstoned)
778        let edges_from_b = edge_store.edges_from(id_b);
779        assert!(
780            edges_from_b.is_empty(),
781            "No edges should be visible from removed node B"
782        );
783
784        // Edges to C should no longer include B (only A->C should be visible)
785        let edges_to_c = edge_store.edges_to(id_c);
786        assert_eq!(
787            edges_to_c.len(),
788            1,
789            "C should have exactly 1 incoming visible edge"
790        );
791        assert_eq!(
792            edges_to_c[0].source, id_a,
793            "C's incoming edge should be from A, not B"
794        );
795    }
796
797    #[test]
798    fn test_cross_file_edges_removed_with_file() {
799        // Tests that cross-file incoming edges are properly removed when a file is removed.
800        // This validates the fix for file-partition mismatch (CRITICAL M7).
801        let mut arena = NodeArena::new();
802        let mut indices = AuxiliaryIndices::new();
803        let edge_store = BidirectionalEdgeStore::new();
804
805        let file_a = test_file(1);
806        let file_b = test_file(2);
807
808        // Create node A in file_a
809        let entry_a = test_entry(NodeKind::Function, test_name(1), file_a);
810        let id_a = arena.alloc(entry_a).unwrap();
811        indices.add(id_a, NodeKind::Function, test_name(1), None, file_a);
812
813        // Create node B in file_b
814        let entry_b = test_entry(NodeKind::Function, test_name(2), file_b);
815        let id_b = arena.alloc(entry_b).unwrap();
816        indices.add(id_b, NodeKind::Function, test_name(2), None, file_b);
817
818        // Add cross-file edge: A (file_a) -> B (file_b)
819        // The edge is associated with file_a (source's file)
820        edge_store.add_edge(
821            id_a,
822            id_b,
823            EdgeKind::Calls {
824                argument_count: 0,
825                is_async: false,
826                resolved_via: ResolvedVia::Direct,
827            },
828            file_a,
829        );
830
831        // Verify edge exists
832        assert_eq!(edge_store.edges_from(id_a).len(), 1);
833        assert_eq!(edge_store.edges_to(id_b).len(), 1);
834
835        // Remove file_b (which contains node B)
836        let mut cleanup = CascadeCleanup::new();
837        let result = cleanup.remove_file(&mut arena, &mut indices, &edge_store, file_b);
838
839        assert_eq!(result.nodes_removed, 1);
840        assert!(!arena.contains(id_b));
841        assert!(arena.contains(id_a));
842
843        // CRITICAL: The incoming edge A->B must be invisible after B's file is removed.
844        // Before the fix, the Remove delta would be stored in file_b's partition,
845        // but the Add delta lives in file_a's partition, so they wouldn't cancel.
846        let edges_from_a = edge_store.edges_from(id_a);
847        assert!(
848            edges_from_a.is_empty(),
849            "Cross-file edge A->B should be invisible after B's file removal"
850        );
851
852        let edges_to_b = edge_store.edges_to(id_b);
853        assert!(
854            edges_to_b.is_empty(),
855            "No edges should be visible to removed node B"
856        );
857    }
858
859    #[test]
860    fn test_delta_only_edge_removal_with_matching_generation() {
861        // Tests that delta-only edges with matching generations are properly removed.
862        // This validates the fix for edge generation mismatch (CRITICAL M7).
863        let mut arena = NodeArena::new();
864        let mut indices = AuxiliaryIndices::new();
865        let edge_store = BidirectionalEdgeStore::new();
866
867        let file = test_file(1);
868
869        // Create nodes A and B
870        let entry_a = test_entry(NodeKind::Function, test_name(1), file);
871        let entry_b = test_entry(NodeKind::Function, test_name(2), file);
872
873        let id_a = arena.alloc(entry_a).unwrap();
874        let id_b = arena.alloc(entry_b).unwrap();
875
876        indices.add(id_a, NodeKind::Function, test_name(1), None, file);
877        indices.add(id_b, NodeKind::Function, test_name(2), None, file);
878
879        // Verify generations are > 0 (nodes are not generation 0)
880        assert!(
881            id_a.generation() > 0,
882            "Node A should have non-zero generation"
883        );
884        assert!(
885            id_b.generation() > 0,
886            "Node B should have non-zero generation"
887        );
888
889        // Add edge A -> B (only in delta buffer, not compacted to CSR)
890        edge_store.add_edge(
891            id_a,
892            id_b,
893            EdgeKind::Calls {
894                argument_count: 0,
895                is_async: false,
896                resolved_via: ResolvedVia::Direct,
897            },
898            file,
899        );
900
901        // Verify edge exists
902        let edges_before = edge_store.edges_from(id_a);
903        assert_eq!(edges_before.len(), 1);
904        assert_eq!(edges_before[0].target, id_b);
905
906        // Remove node B
907        let mut cleanup = CascadeCleanup::new();
908        let result = cleanup.remove_node(&mut arena, &mut indices, &edge_store, id_b);
909
910        assert!(result.was_removed());
911        assert_eq!(result.edges_invalidated, 1);
912
913        // CRITICAL: Edge A->B must be invisible after B is removed.
914        // Before the fix, the Remove delta would use generation 0,
915        // but the Add delta has the real generation, so they wouldn't match.
916        let edges_after = edge_store.edges_from(id_a);
917        assert!(
918            edges_after.is_empty(),
919            "Delta-only edge A->B should be invisible after B removal"
920        );
921
922        let edges_to_b = edge_store.edges_to(id_b);
923        assert!(
924            edges_to_b.is_empty(),
925            "No edges should be visible to removed node B"
926        );
927    }
928}