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;
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            },
449            file,
450        );
451        edge_store.add_edge(
452            id3,
453            id1,
454            EdgeKind::Calls {
455                argument_count: 0,
456                is_async: false,
457            },
458            file,
459        );
460
461        // Verify edges exist
462        assert_eq!(edge_store.edges_from(id1).len(), 1);
463        assert_eq!(edge_store.edges_to(id1).len(), 1);
464
465        // Remove id1
466        let mut cleanup = CascadeCleanup::new();
467        let result = cleanup.remove_node(&mut arena, &mut indices, &edge_store, id1);
468
469        assert!(result.was_removed());
470        assert_eq!(result.edges_invalidated, 2); // one outgoing, one incoming
471
472        // Node should be gone
473        assert!(!arena.contains(id1));
474        assert_eq!(indices.len(), 2);
475
476        // Stats should reflect the removal
477        assert_eq!(cleanup.stats().nodes_removed, 1);
478        assert_eq!(cleanup.stats().edges_invalidated, 2);
479    }
480
481    #[test]
482    fn test_remove_node_with_metadata() {
483        let mut arena = NodeArena::new();
484        let mut indices = AuxiliaryIndices::new();
485        let edge_store = BidirectionalEdgeStore::new();
486
487        let file = test_file(1);
488        let name = test_name(1);
489        let entry = test_entry(NodeKind::Function, name, file);
490
491        let node_id = arena.alloc(entry).unwrap();
492        indices.add(node_id, NodeKind::Function, name, None, file);
493
494        let mut cleanup = CascadeCleanup::new();
495        let result = cleanup.remove_node_with_metadata(
496            &mut arena,
497            &mut indices,
498            &edge_store,
499            node_id,
500            NodeKind::Function,
501            name,
502            None,
503            file,
504        );
505
506        assert!(result.was_removed());
507        assert!(result.removed_from_indices);
508        assert!(!arena.contains(node_id));
509        assert_eq!(indices.len(), 0);
510    }
511
512    #[test]
513    fn test_remove_file() {
514        let mut arena = NodeArena::new();
515        let mut indices = AuxiliaryIndices::new();
516        let edge_store = BidirectionalEdgeStore::new();
517
518        let file1 = test_file(1);
519        let file2 = test_file(2);
520
521        // Create nodes in file1
522        let entry1 = test_entry(NodeKind::Function, test_name(1), file1);
523        let entry2 = test_entry(NodeKind::Class, test_name(2), file1);
524        let entry3 = test_entry(NodeKind::Method, test_name(3), file1);
525
526        // Create node in file2
527        let entry4 = test_entry(NodeKind::Function, test_name(4), file2);
528
529        let id1 = arena.alloc(entry1).unwrap();
530        let id2 = arena.alloc(entry2).unwrap();
531        let id3 = arena.alloc(entry3).unwrap();
532        let id4 = arena.alloc(entry4).unwrap();
533
534        indices.add(id1, NodeKind::Function, test_name(1), None, file1);
535        indices.add(id2, NodeKind::Class, test_name(2), None, file1);
536        indices.add(id3, NodeKind::Method, test_name(3), None, file1);
537        indices.add(id4, NodeKind::Function, test_name(4), None, file2);
538
539        // Add edges within file1
540        edge_store.add_edge(
541            id1,
542            id2,
543            EdgeKind::Calls {
544                argument_count: 0,
545                is_async: false,
546            },
547            file1,
548        );
549        edge_store.add_edge(
550            id2,
551            id3,
552            EdgeKind::Calls {
553                argument_count: 0,
554                is_async: false,
555            },
556            file1,
557        );
558
559        assert_eq!(arena.len(), 4);
560        assert_eq!(indices.len(), 4);
561
562        // Remove file1
563        let mut cleanup = CascadeCleanup::new();
564        let result = cleanup.remove_file(&mut arena, &mut indices, &edge_store, file1);
565
566        assert_eq!(result.file, file1);
567        assert_eq!(result.nodes_removed, 3);
568
569        // Verify file1 nodes are gone
570        assert!(!arena.contains(id1));
571        assert!(!arena.contains(id2));
572        assert!(!arena.contains(id3));
573
574        // Verify file2 node remains
575        assert!(arena.contains(id4));
576
577        // Verify indices updated
578        assert_eq!(indices.len(), 1);
579        assert!(indices.by_file(file1).is_empty());
580        assert_eq!(indices.by_file(file2).len(), 1);
581
582        // Verify stats
583        assert_eq!(cleanup.stats().nodes_removed, 3);
584        assert_eq!(cleanup.stats().files_processed, 1);
585    }
586
587    #[test]
588    fn test_remove_empty_file() {
589        let mut arena = NodeArena::new();
590        let mut indices = AuxiliaryIndices::new();
591        let edge_store = BidirectionalEdgeStore::new();
592
593        let file = test_file(1);
594
595        let mut cleanup = CascadeCleanup::new();
596        let result = cleanup.remove_file(&mut arena, &mut indices, &edge_store, file);
597
598        assert_eq!(result.nodes_removed, 0);
599        assert_eq!(result.edges_invalidated, 0);
600    }
601
602    #[test]
603    fn test_convenience_cascade_remove_node() {
604        let mut arena = NodeArena::new();
605        let mut indices = AuxiliaryIndices::new();
606        let edge_store = BidirectionalEdgeStore::new();
607
608        let file = test_file(1);
609        let name = test_name(1);
610        let entry = test_entry(NodeKind::Function, name, file);
611
612        let node_id = arena.alloc(entry).unwrap();
613        indices.add(node_id, NodeKind::Function, name, None, file);
614
615        let result = cascade_remove_node(&mut arena, &mut indices, &edge_store, node_id);
616
617        assert!(result.was_removed());
618        assert!(!arena.contains(node_id));
619    }
620
621    #[test]
622    fn test_convenience_cascade_remove_file() {
623        let mut arena = NodeArena::new();
624        let mut indices = AuxiliaryIndices::new();
625        let edge_store = BidirectionalEdgeStore::new();
626
627        let file = test_file(1);
628
629        let entry = test_entry(NodeKind::Function, test_name(1), file);
630        let node_id = arena.alloc(entry).unwrap();
631        indices.add(node_id, NodeKind::Function, test_name(1), None, file);
632
633        let result = cascade_remove_file(&mut arena, &mut indices, &edge_store, file);
634
635        assert_eq!(result.nodes_removed, 1);
636        assert!(!arena.contains(node_id));
637    }
638
639    #[test]
640    fn test_reset_stats() {
641        let mut cleanup = CascadeCleanup::new();
642        let mut arena = NodeArena::new();
643        let mut indices = AuxiliaryIndices::new();
644        let edge_store = BidirectionalEdgeStore::new();
645
646        let file = test_file(1);
647        let entry = test_entry(NodeKind::Function, test_name(1), file);
648        let node_id = arena.alloc(entry).unwrap();
649        indices.add(node_id, NodeKind::Function, test_name(1), None, file);
650
651        cleanup.remove_node(&mut arena, &mut indices, &edge_store, node_id);
652
653        assert_eq!(cleanup.stats().nodes_removed, 1);
654
655        cleanup.reset_stats();
656
657        assert_eq!(cleanup.stats().nodes_removed, 0);
658        assert_eq!(cleanup.stats().edges_invalidated, 0);
659    }
660
661    #[test]
662    fn test_cascade_removal_result_methods() {
663        let result_with_entry = CascadeRemovalResult {
664            node_id: NodeId::new(1, 1),
665            entry: Some(NodeEntry::new(
666                NodeKind::Function,
667                StringId::new(1),
668                FileId::new(1),
669            )),
670            edges_invalidated: 5,
671            removed_from_indices: true,
672        };
673        assert!(result_with_entry.was_removed());
674
675        let result_without_entry = CascadeRemovalResult {
676            node_id: NodeId::new(2, 1),
677            entry: None,
678            edges_invalidated: 0,
679            removed_from_indices: false,
680        };
681        assert!(!result_without_entry.was_removed());
682    }
683
684    #[test]
685    fn test_no_dangling_edges_after_removal() {
686        // Comprehensive test for No dangling edges after node removal
687        let mut arena = NodeArena::new();
688        let mut indices = AuxiliaryIndices::new();
689        let edge_store = BidirectionalEdgeStore::new();
690
691        let file = test_file(1);
692
693        // Create a graph: A -> B -> C, A -> C
694        let entry_a = test_entry(NodeKind::Function, test_name(1), file);
695        let entry_b = test_entry(NodeKind::Function, test_name(2), file);
696        let entry_c = test_entry(NodeKind::Function, test_name(3), file);
697
698        let id_a = arena.alloc(entry_a).unwrap();
699        let id_b = arena.alloc(entry_b).unwrap();
700        let id_c = arena.alloc(entry_c).unwrap();
701
702        indices.add(id_a, NodeKind::Function, test_name(1), None, file);
703        indices.add(id_b, NodeKind::Function, test_name(2), None, file);
704        indices.add(id_c, NodeKind::Function, test_name(3), None, file);
705
706        edge_store.add_edge(
707            id_a,
708            id_b,
709            EdgeKind::Calls {
710                argument_count: 0,
711                is_async: false,
712            },
713            file,
714        );
715        edge_store.add_edge(
716            id_b,
717            id_c,
718            EdgeKind::Calls {
719                argument_count: 0,
720                is_async: false,
721            },
722            file,
723        );
724        edge_store.add_edge(
725            id_a,
726            id_c,
727            EdgeKind::Calls {
728                argument_count: 0,
729                is_async: false,
730            },
731            file,
732        );
733
734        // Remove node B (middle of chain)
735        let mut cleanup = CascadeCleanup::new();
736        let result = cleanup.remove_node(&mut arena, &mut indices, &edge_store, id_b);
737
738        // Verify B was removed
739        assert!(result.was_removed());
740        assert!(!arena.contains(id_b));
741
742        // Verify A and C still exist
743        assert!(arena.contains(id_a));
744        assert!(arena.contains(id_c));
745
746        // Verify edges from/to B have tombstones (will be filtered in queries)
747        // After removal, querying edges from B should show removed edges have tombstones
748        assert_eq!(result.edges_invalidated, 2); // A->B and B->C
749
750        // CRITICAL: Assert edge visibility post-removal
751        // Edges from A should no longer include B (only A->C should remain visible)
752        let edges_from_a = edge_store.edges_from(id_a);
753        assert_eq!(
754            edges_from_a.len(),
755            1,
756            "A should have exactly 1 visible edge (A->C)"
757        );
758        assert_eq!(
759            edges_from_a[0].target, id_c,
760            "A's edge should point to C, not B"
761        );
762
763        // Edges to B should be empty (A->B has been tombstoned)
764        let edges_to_b = edge_store.edges_to(id_b);
765        assert!(
766            edges_to_b.is_empty(),
767            "No edges should be visible to removed node B"
768        );
769
770        // Edges from B should be empty (B->C has been tombstoned)
771        let edges_from_b = edge_store.edges_from(id_b);
772        assert!(
773            edges_from_b.is_empty(),
774            "No edges should be visible from removed node B"
775        );
776
777        // Edges to C should no longer include B (only A->C should be visible)
778        let edges_to_c = edge_store.edges_to(id_c);
779        assert_eq!(
780            edges_to_c.len(),
781            1,
782            "C should have exactly 1 incoming visible edge"
783        );
784        assert_eq!(
785            edges_to_c[0].source, id_a,
786            "C's incoming edge should be from A, not B"
787        );
788    }
789
790    #[test]
791    fn test_cross_file_edges_removed_with_file() {
792        // Tests that cross-file incoming edges are properly removed when a file is removed.
793        // This validates the fix for file-partition mismatch (CRITICAL M7).
794        let mut arena = NodeArena::new();
795        let mut indices = AuxiliaryIndices::new();
796        let edge_store = BidirectionalEdgeStore::new();
797
798        let file_a = test_file(1);
799        let file_b = test_file(2);
800
801        // Create node A in file_a
802        let entry_a = test_entry(NodeKind::Function, test_name(1), file_a);
803        let id_a = arena.alloc(entry_a).unwrap();
804        indices.add(id_a, NodeKind::Function, test_name(1), None, file_a);
805
806        // Create node B in file_b
807        let entry_b = test_entry(NodeKind::Function, test_name(2), file_b);
808        let id_b = arena.alloc(entry_b).unwrap();
809        indices.add(id_b, NodeKind::Function, test_name(2), None, file_b);
810
811        // Add cross-file edge: A (file_a) -> B (file_b)
812        // The edge is associated with file_a (source's file)
813        edge_store.add_edge(
814            id_a,
815            id_b,
816            EdgeKind::Calls {
817                argument_count: 0,
818                is_async: false,
819            },
820            file_a,
821        );
822
823        // Verify edge exists
824        assert_eq!(edge_store.edges_from(id_a).len(), 1);
825        assert_eq!(edge_store.edges_to(id_b).len(), 1);
826
827        // Remove file_b (which contains node B)
828        let mut cleanup = CascadeCleanup::new();
829        let result = cleanup.remove_file(&mut arena, &mut indices, &edge_store, file_b);
830
831        assert_eq!(result.nodes_removed, 1);
832        assert!(!arena.contains(id_b));
833        assert!(arena.contains(id_a));
834
835        // CRITICAL: The incoming edge A->B must be invisible after B's file is removed.
836        // Before the fix, the Remove delta would be stored in file_b's partition,
837        // but the Add delta lives in file_a's partition, so they wouldn't cancel.
838        let edges_from_a = edge_store.edges_from(id_a);
839        assert!(
840            edges_from_a.is_empty(),
841            "Cross-file edge A->B should be invisible after B's file removal"
842        );
843
844        let edges_to_b = edge_store.edges_to(id_b);
845        assert!(
846            edges_to_b.is_empty(),
847            "No edges should be visible to removed node B"
848        );
849    }
850
851    #[test]
852    fn test_delta_only_edge_removal_with_matching_generation() {
853        // Tests that delta-only edges with matching generations are properly removed.
854        // This validates the fix for edge generation mismatch (CRITICAL M7).
855        let mut arena = NodeArena::new();
856        let mut indices = AuxiliaryIndices::new();
857        let edge_store = BidirectionalEdgeStore::new();
858
859        let file = test_file(1);
860
861        // Create nodes A and B
862        let entry_a = test_entry(NodeKind::Function, test_name(1), file);
863        let entry_b = test_entry(NodeKind::Function, test_name(2), file);
864
865        let id_a = arena.alloc(entry_a).unwrap();
866        let id_b = arena.alloc(entry_b).unwrap();
867
868        indices.add(id_a, NodeKind::Function, test_name(1), None, file);
869        indices.add(id_b, NodeKind::Function, test_name(2), None, file);
870
871        // Verify generations are > 0 (nodes are not generation 0)
872        assert!(
873            id_a.generation() > 0,
874            "Node A should have non-zero generation"
875        );
876        assert!(
877            id_b.generation() > 0,
878            "Node B should have non-zero generation"
879        );
880
881        // Add edge A -> B (only in delta buffer, not compacted to CSR)
882        edge_store.add_edge(
883            id_a,
884            id_b,
885            EdgeKind::Calls {
886                argument_count: 0,
887                is_async: false,
888            },
889            file,
890        );
891
892        // Verify edge exists
893        let edges_before = edge_store.edges_from(id_a);
894        assert_eq!(edges_before.len(), 1);
895        assert_eq!(edges_before[0].target, id_b);
896
897        // Remove node B
898        let mut cleanup = CascadeCleanup::new();
899        let result = cleanup.remove_node(&mut arena, &mut indices, &edge_store, id_b);
900
901        assert!(result.was_removed());
902        assert_eq!(result.edges_invalidated, 1);
903
904        // CRITICAL: Edge A->B must be invisible after B is removed.
905        // Before the fix, the Remove delta would use generation 0,
906        // but the Add delta has the real generation, so they wouldn't match.
907        let edges_after = edge_store.edges_from(id_a);
908        assert!(
909            edges_after.is_empty(),
910            "Delta-only edge A->B should be invisible after B removal"
911        );
912
913        let edges_to_b = edge_store.edges_to(id_b);
914        assert!(
915            edges_to_b.is_empty(),
916            "No edges should be visible to removed node B"
917        );
918    }
919}