Skip to main content

sqry_core/graph/unified/concurrent/
graph.rs

1//! `CodeGraph` and `ConcurrentCodeGraph` implementations.
2//!
3//! This module provides the core graph types with thread-safe access:
4//!
5//! - [`CodeGraph`]: Arc-wrapped internals for O(1) `CoW` snapshots
6//! - [`ConcurrentCodeGraph`]: `RwLock` wrapper with epoch versioning
7//! - [`GraphSnapshot`]: Immutable snapshot for long-running queries
8
9use std::collections::{HashMap, HashSet};
10use std::fmt;
11use std::sync::Arc;
12use std::sync::atomic::{AtomicU64, Ordering};
13
14use parking_lot::{RwLock, RwLockReadGuard, RwLockWriteGuard};
15
16use crate::confidence::ConfidenceMetadata;
17use crate::graph::unified::bind::alias::AliasTable;
18use crate::graph::unified::bind::scope::provenance::{
19    ScopeProvenance, ScopeProvenanceStore, ScopeStableId,
20};
21use crate::graph::unified::bind::scope::{ScopeArena, ScopeId};
22use crate::graph::unified::bind::shadow::ShadowTable;
23use crate::graph::unified::edge::EdgeKind;
24#[cfg(test)]
25use crate::graph::unified::edge::ResolvedVia;
26use crate::graph::unified::edge::bidirectional::BidirectionalEdgeStore;
27use crate::graph::unified::file::FileId;
28use crate::graph::unified::memory::{GraphMemorySize, HASHMAP_ENTRY_OVERHEAD};
29use crate::graph::unified::resolution::display_graph_qualified_name;
30use crate::graph::unified::storage::arena::NodeArena;
31use crate::graph::unified::storage::c_indirect::CIndirectSideTables;
32use crate::graph::unified::storage::edge_provenance::{EdgeProvenance, EdgeProvenanceStore};
33use crate::graph::unified::storage::indices::AuxiliaryIndices;
34use crate::graph::unified::storage::interner::StringInterner;
35use crate::graph::unified::storage::metadata::NodeMetadataStore;
36use crate::graph::unified::storage::node_provenance::{NodeProvenance, NodeProvenanceStore};
37use crate::graph::unified::storage::registry::{FileProvenanceView, FileRegistry};
38use crate::graph::unified::storage::segment::FileSegmentTable;
39use crate::graph::unified::string::id::StringId;
40
41/// Core graph with Arc-wrapped internals for O(1) `CoW` snapshots.
42///
43/// `CodeGraph` uses `Arc` for all internal components, enabling:
44/// - O(1) snapshot creation via Arc cloning
45/// - Copy-on-write semantics via `Arc::make_mut`
46/// - Memory-efficient sharing between snapshots
47///
48/// # Design
49///
50/// The Arc wrapping enables the MVCC pattern:
51/// - Readers see a consistent snapshot at the time they acquired access
52/// - Writers use `Arc::make_mut` to get exclusive copies only when mutating
53/// - Multiple snapshots can coexist without copying data
54///
55/// # Performance
56///
57/// - Snapshot creation: O(5) Arc clones ≈ <1μs
58/// - Read access: Direct Arc dereference, no locking
59/// - Write access: `Arc::make_mut` clones only if refcount > 1
60///
61/// # Phase 2 binding-plane access
62///
63/// Use the two-line snapshot pattern to access `BindingPlane`:
64///
65/// ```rust,ignore
66/// let snapshot = graph.snapshot();
67/// let plane = snapshot.binding_plane();
68/// let resolution = plane.resolve(&query);
69/// ```
70///
71/// The two-line form is intentional: `BindingPlane<'g>` borrows from
72/// `GraphSnapshot` and the explicit snapshot handle makes the MVCC lifetime
73/// visible at the call site. The full Phase 2 scope/alias/shadow and
74/// witness-bearing resolution API is exposed through `BindingPlane`.
75// Field visibility is `pub(crate)` so the Gate 0c `rebuild_graph` module
76// (A2 §H) can destructure `CodeGraph` exhaustively in `clone_for_rebuild`.
77// External crates still go through the public accessor methods below.
78#[derive(Clone)]
79pub struct CodeGraph {
80    /// Node storage with generational indices.
81    pub(crate) nodes: Arc<NodeArena>,
82    /// Bidirectional edge storage (forward + reverse).
83    pub(crate) edges: Arc<BidirectionalEdgeStore>,
84    /// String interner for symbol names.
85    pub(crate) strings: Arc<StringInterner>,
86    /// File registry for path deduplication.
87    pub(crate) files: Arc<FileRegistry>,
88    /// Auxiliary indices for fast lookup.
89    pub(crate) indices: Arc<AuxiliaryIndices>,
90    /// Sparse macro boundary metadata (keyed by full `NodeId`).
91    pub(crate) macro_metadata: Arc<NodeMetadataStore>,
92    /// Dense node provenance (Phase 1 fact layer).
93    pub(crate) node_provenance: Arc<NodeProvenanceStore>,
94    /// Dense edge provenance (Phase 1 fact layer).
95    pub(crate) edge_provenance: Arc<EdgeProvenanceStore>,
96    /// Monotonic fact-layer epoch (0 until set by the V8 persistence path).
97    pub(crate) fact_epoch: u64,
98    /// Epoch for version tracking.
99    pub(crate) epoch: u64,
100    /// Per-language confidence metadata collected during build.
101    /// Maps language name (e.g., "rust") to aggregated confidence.
102    pub(crate) confidence: HashMap<String, ConfidenceMetadata>,
103    /// Phase 2 binding-plane scope arena (populated by Phase 4e).
104    pub(crate) scope_arena: Arc<ScopeArena>,
105    /// Phase 2 binding-plane alias table (populated by Phase 4e / P2U04).
106    pub(crate) alias_table: Arc<AliasTable>,
107    /// Phase 2 binding-plane shadow table (populated by Phase 4e / P2U05).
108    pub(crate) shadow_table: Arc<ShadowTable>,
109    /// Phase 2 binding-plane scope provenance store (populated by Phase 4e / P2U11).
110    pub(crate) scope_provenance_store: Arc<ScopeProvenanceStore>,
111    /// Phase 3 file segment table mapping `FileId` to contiguous node ranges.
112    /// Populated during build Phase 3 parallel commit, persisted in V10+ snapshots.
113    pub(crate) file_segments: Arc<FileSegmentTable>,
114    /// Phase A (U09): C-only indirect-call resolver side tables.
115    ///
116    /// `None` on non-C workspaces — the parent slot stays unallocated so
117    /// non-C builds incur zero side-table overhead. Populated by Phase 3
118    /// commit (U11) and consumed by `pass5b_c_indirect_resolve` (U12).
119    /// Persisted as the V11 `Option<CIndirectSideTables>` envelope slot
120    /// (DESIGN §10.2); V10 → V11 upconvert sets this to `None`.
121    pub(crate) c_indirect_tables: Option<CIndirectSideTables>,
122    /// Build-time scratch side-channel from the Go plugin (Cluster A
123    /// of the Go T1 implements-and-promotion design).
124    ///
125    /// Populated during Phase 1 plugin parse, merged into the live
126    /// target by Phase 3 commit after `NodeId` / `StringId` remap, drained
127    /// by `pass_go_method_set_satisfaction` between Phase 4e and Pass
128    /// 5. Not part of `GraphSnapshot`, not persisted in V10 — see
129    /// `02_DESIGN` §6. Held by-value (not `Arc<…>`) because no
130    /// copy-on-write or shared-reader access is required: only the
131    /// rebuild owner mutates, only the pass consumes, then the field
132    /// is reset.
133    pub(crate) go_hints: crate::graph::unified::build::staging::GoHints,
134    /// Whether this graph carries genuine `NodeEntry.is_definition` signal
135    /// (R3 marker for the definition-signal feature).
136    ///
137    /// `true` for graphs freshly built in-process (the `GraphBuilder` path stamps
138    /// `is_definition` at declaration sites) and for graphs loaded from a
139    /// V16-or-newer snapshot. `false` for graphs loaded from a pre-V16 snapshot,
140    /// whose `is_definition` bits are all defaulted `false` (absent on the wire)
141    /// and must NOT be interpreted as "no declarations exist." Consumers that
142    /// filter on `is_definition` (e.g. items-only listings) read this marker to
143    /// decide whether the signal is trustworthy or a reindex is required.
144    pub(crate) definition_signal_present: bool,
145    /// Whether this graph carries genuine import-classification signal
146    /// (`NodeFlags::IMPORT_STDLIB` / `NodeFlags::IMPORT_RELATIVE` on import
147    /// nodes, issue #467).
148    ///
149    /// `true` for graphs freshly built in-process (the Go `GraphBuilder` path
150    /// classifies imports as it commits them) and for graphs loaded from a
151    /// V17-or-newer snapshot. `false` for graphs loaded from a pre-V17 snapshot,
152    /// whose import nodes never had their classification bits computed: the bits
153    /// are all clear, and clear must NOT be interpreted as "classified, not
154    /// stdlib." Consumers that read `IMPORT_STDLIB` / `IMPORT_RELATIVE` check
155    /// this marker to decide whether a clear bit is authoritative or whether a
156    /// reindex is required to obtain the signal.
157    pub(crate) import_classification_signal_present: bool,
158}
159
160impl CodeGraph {
161    /// Creates a new empty `CodeGraph`.
162    ///
163    /// # Example
164    ///
165    /// ```rust
166    /// use sqry_core::graph::unified::concurrent::CodeGraph;
167    ///
168    /// let graph = CodeGraph::new();
169    /// assert_eq!(graph.epoch(), 0);
170    /// ```
171    #[must_use]
172    pub fn new() -> Self {
173        Self {
174            nodes: Arc::new(NodeArena::new()),
175            edges: Arc::new(BidirectionalEdgeStore::new()),
176            strings: Arc::new(StringInterner::new()),
177            files: Arc::new(FileRegistry::new()),
178            indices: Arc::new(AuxiliaryIndices::new()),
179            macro_metadata: Arc::new(NodeMetadataStore::new()),
180            node_provenance: Arc::new(NodeProvenanceStore::new()),
181            edge_provenance: Arc::new(EdgeProvenanceStore::new()),
182            fact_epoch: 0,
183            epoch: 0,
184            confidence: HashMap::new(),
185            scope_arena: Arc::new(ScopeArena::new()),
186            alias_table: Arc::new(AliasTable::new()),
187            shadow_table: Arc::new(ShadowTable::new()),
188            scope_provenance_store: Arc::new(ScopeProvenanceStore::new()),
189            file_segments: Arc::new(FileSegmentTable::new()),
190            c_indirect_tables: None,
191            go_hints: crate::graph::unified::build::staging::GoHints::default(),
192            // Fresh in-process graph: the GraphBuilder path stamps is_definition.
193            definition_signal_present: true,
194            // Fresh in-process graph: the Go GraphBuilder path classifies imports.
195            import_classification_signal_present: true,
196        }
197    }
198
199    /// Creates a `CodeGraph` from existing components.
200    ///
201    /// This is useful when building a graph from external data or
202    /// reconstructing from serialized state.
203    #[must_use]
204    pub fn from_components(
205        nodes: NodeArena,
206        edges: BidirectionalEdgeStore,
207        strings: StringInterner,
208        files: FileRegistry,
209        indices: AuxiliaryIndices,
210        macro_metadata: NodeMetadataStore,
211    ) -> Self {
212        Self {
213            nodes: Arc::new(nodes),
214            edges: Arc::new(edges),
215            strings: Arc::new(strings),
216            files: Arc::new(files),
217            indices: Arc::new(indices),
218            macro_metadata: Arc::new(macro_metadata),
219            node_provenance: Arc::new(NodeProvenanceStore::new()),
220            edge_provenance: Arc::new(EdgeProvenanceStore::new()),
221            fact_epoch: 0,
222            epoch: 0,
223            confidence: HashMap::new(),
224            scope_arena: Arc::new(ScopeArena::new()),
225            alias_table: Arc::new(AliasTable::new()),
226            shadow_table: Arc::new(ShadowTable::new()),
227            scope_provenance_store: Arc::new(ScopeProvenanceStore::new()),
228            file_segments: Arc::new(FileSegmentTable::new()),
229            c_indirect_tables: None,
230            go_hints: crate::graph::unified::build::staging::GoHints::default(),
231            // Default to present; the load path overrides via
232            // `set_definition_signal_present` based on the snapshot format
233            // version (pre-V16 loads stamp `false`).
234            definition_signal_present: true,
235            // Default to present; the load path overrides via
236            // `set_import_classification_signal_present` based on the snapshot
237            // format version (pre-V17 loads stamp `false`).
238            import_classification_signal_present: true,
239        }
240    }
241
242    /// Creates a cheap snapshot of the graph.
243    ///
244    /// This operation is O(5) Arc clones, which completes in <1μs.
245    /// The snapshot is isolated from future mutations to the original graph.
246    ///
247    /// # Example
248    ///
249    /// ```rust
250    /// use sqry_core::graph::unified::concurrent::CodeGraph;
251    ///
252    /// let graph = CodeGraph::new();
253    /// let snapshot = graph.snapshot();
254    /// // snapshot is independent of future mutations to graph
255    /// ```
256    #[must_use]
257    pub fn snapshot(&self) -> GraphSnapshot {
258        GraphSnapshot {
259            nodes: Arc::clone(&self.nodes),
260            edges: Arc::clone(&self.edges),
261            strings: Arc::clone(&self.strings),
262            files: Arc::clone(&self.files),
263            indices: Arc::clone(&self.indices),
264            macro_metadata: Arc::clone(&self.macro_metadata),
265            node_provenance: Arc::clone(&self.node_provenance),
266            edge_provenance: Arc::clone(&self.edge_provenance),
267            fact_epoch: self.fact_epoch,
268            epoch: self.epoch,
269            scope_arena: Arc::clone(&self.scope_arena),
270            alias_table: Arc::clone(&self.alias_table),
271            shadow_table: Arc::clone(&self.shadow_table),
272            scope_provenance_store: Arc::clone(&self.scope_provenance_store),
273            file_segments: Arc::clone(&self.file_segments),
274            c_indirect_tables: self.c_indirect_tables.clone(),
275            definition_signal_present: self.definition_signal_present,
276            import_classification_signal_present: self.import_classification_signal_present,
277        }
278    }
279
280    /// Returns whether this graph carries genuine `is_definition` signal.
281    ///
282    /// See [`definition_signal_present`](Self::definition_signal_present) (the
283    /// field) for the full contract.
284    #[inline]
285    #[must_use]
286    pub fn definition_signal_present(&self) -> bool {
287        self.definition_signal_present
288    }
289
290    /// Sets the definition-signal marker.
291    ///
292    /// Used by the snapshot load path to stamp `false` for pre-V16 snapshots
293    /// (whose `is_definition` bits are all defaulted) and `true` for V16+.
294    #[inline]
295    pub fn set_definition_signal_present(&mut self, present: bool) {
296        self.definition_signal_present = present;
297    }
298
299    /// Returns whether this graph carries genuine import-classification signal.
300    ///
301    /// See [`import_classification_signal_present`](Self::import_classification_signal_present)
302    /// (the field) for the full contract.
303    #[inline]
304    #[must_use]
305    pub fn import_classification_signal_present(&self) -> bool {
306        self.import_classification_signal_present
307    }
308
309    /// Sets the import-classification-signal marker.
310    ///
311    /// Used by the snapshot load path to stamp `false` for pre-V17 snapshots
312    /// (whose import classification bits are all cleared but were never
313    /// computed) and `true` for V17+.
314    #[inline]
315    pub fn set_import_classification_signal_present(&mut self, present: bool) {
316        self.import_classification_signal_present = present;
317    }
318
319    /// Returns a reference to the node arena.
320    #[inline]
321    #[must_use]
322    pub fn nodes(&self) -> &NodeArena {
323        &self.nodes
324    }
325
326    /// Returns a reference to the bidirectional edge store.
327    #[inline]
328    #[must_use]
329    pub fn edges(&self) -> &BidirectionalEdgeStore {
330        &self.edges
331    }
332
333    /// Returns a reference to the string interner.
334    #[inline]
335    #[must_use]
336    pub fn strings(&self) -> &StringInterner {
337        &self.strings
338    }
339
340    /// Returns a reference to the file registry.
341    #[inline]
342    #[must_use]
343    pub fn files(&self) -> &FileRegistry {
344        &self.files
345    }
346
347    /// Returns a reference to the auxiliary indices.
348    #[inline]
349    #[must_use]
350    pub fn indices(&self) -> &AuxiliaryIndices {
351        &self.indices
352    }
353
354    /// Returns a reference to the macro boundary metadata store.
355    #[inline]
356    #[must_use]
357    pub fn macro_metadata(&self) -> &NodeMetadataStore {
358        &self.macro_metadata
359    }
360
361    /// Returns a reference to the C indirect-call side tables, if any.
362    ///
363    /// `None` on non-C workspaces — the slot is allocated lazily and only
364    /// when the C plugin's Phase 1 instrumentation observes content worth
365    /// recording. On loaded snapshots, the V11 envelope's
366    /// `Option<CIndirectSideTables>` field round-trips into this slot
367    /// (DESIGN §10.2); V10 → V11 upconvert always sets the slot to `None`.
368    ///
369    /// `pass5b_c_indirect_resolve` (U12) consumes these tables to rewrite
370    /// synthetic indirect-call `Calls` edges into precise binding-plane /
371    /// type-match candidates.
372    #[inline]
373    #[must_use]
374    pub fn c_indirect_tables(&self) -> Option<&CIndirectSideTables> {
375        self.c_indirect_tables.as_ref()
376    }
377
378    /// Returns a mutable reference to the `Option<CIndirectSideTables>`
379    /// slot, allowing callers to install / replace / clear the side
380    /// tables.
381    ///
382    /// Mirrors the accessor pattern used for [`Self::macro_metadata_mut`]
383    /// but exposes the `Option` directly — the side tables are owned in
384    /// place, not behind an `Arc`. Callers that need to merge incremental
385    /// state into the existing tables typically do:
386    ///
387    /// ```rust,ignore
388    /// let slot = graph.c_indirect_tables_mut();
389    /// let tables = slot.get_or_insert_with(CIndirectSideTables::new);
390    /// tables.bindings_by_field.entry(key).or_default().push(entry);
391    /// ```
392    ///
393    /// Populated by the build pipeline (U11). Read-only consumers should
394    /// use [`Self::c_indirect_tables`] instead.
395    #[inline]
396    pub fn c_indirect_tables_mut(&mut self) -> &mut Option<CIndirectSideTables> {
397        &mut self.c_indirect_tables
398    }
399
400    /// Test-only: strip every Phase-A-introduced piece of state from this
401    /// graph, leaving a graph that is byte-identical (modulo persistence
402    /// header timestamps) to what a pre-Phase-A build would have produced
403    /// on the same fixture.
404    ///
405    /// Specifically:
406    ///
407    /// * Clears the [`CIndirectSideTables`] slot (sets it to `None`).
408    /// * Removes [`NodeFlags::ADDRESS_TAKEN`] and
409    ///   [`NodeFlags::CALLSITE_PROMISCUOUS`] marker bits from every
410    ///   [`StoredEntry`] in the macro-metadata store.
411    /// * Rewrites the `resolved_via` field of every [`EdgeKind::Calls`]
412    ///   edge (across CSR and delta tiers, forward and reverse stores)
413    ///   to [`ResolvedVia::Direct`].
414    ///
415    /// Used exclusively by `sqry-core/tests/snapshot_size_phase_a.rs`
416    /// (U19) to materialize a "Phase-A-free" baseline snapshot for the
417    /// +10% snapshot-size gate. Gated behind `cfg(any(test, feature =
418    /// "test-support"))` so the helper is invisible to production
419    /// builds.
420    ///
421    /// [`NodeFlags::ADDRESS_TAKEN`]: crate::graph::unified::storage::metadata::NodeFlags::ADDRESS_TAKEN
422    /// [`NodeFlags::CALLSITE_PROMISCUOUS`]: crate::graph::unified::storage::metadata::NodeFlags::CALLSITE_PROMISCUOUS
423    /// [`StoredEntry`]: crate::graph::unified::storage::metadata::StoredEntry
424    /// [`EdgeKind::Calls`]: crate::graph::unified::edge::EdgeKind::Calls
425    /// [`ResolvedVia::Direct`]: crate::graph::unified::edge::ResolvedVia::Direct
426    #[cfg(any(test, feature = "test-support"))]
427    pub fn clear_phase_a_state_for_test(&mut self) {
428        *self.c_indirect_tables_mut() = None;
429        self.macro_metadata_mut().clear_phase_a_flags_for_test();
430        self.edges_mut().normalize_calls_resolved_via_for_test();
431    }
432
433    // ------------------------------------------------------------------
434    // Phase 1 fact-layer provenance accessors (P1U09).
435    // ------------------------------------------------------------------
436
437    /// Returns the monotonic fact-layer epoch stamped on the most recently
438    /// saved or loaded snapshot. Returns `0` for graphs that have not been
439    /// persisted yet or were loaded from V7 snapshots.
440    #[inline]
441    #[must_use]
442    pub fn fact_epoch(&self) -> u64 {
443        self.fact_epoch
444    }
445
446    /// Looks up node provenance by `NodeId`.
447    ///
448    /// Returns `None` if the `NodeId` is out of range, the slot is vacant,
449    /// or the stored generation does not match (stale handle).
450    #[inline]
451    #[must_use]
452    pub fn node_provenance(
453        &self,
454        id: crate::graph::unified::node::id::NodeId,
455    ) -> Option<&NodeProvenance> {
456        self.node_provenance.lookup(id)
457    }
458
459    /// Looks up edge provenance by `EdgeId`.
460    ///
461    /// Returns `None` if the `EdgeId` is out of range, the slot is vacant,
462    /// or the edge is the invalid sentinel.
463    #[inline]
464    #[must_use]
465    pub fn edge_provenance(
466        &self,
467        id: crate::graph::unified::edge::id::EdgeId,
468    ) -> Option<&EdgeProvenance> {
469        self.edge_provenance.lookup(id)
470    }
471
472    /// Returns a borrowed provenance view for a file.
473    ///
474    /// Returns `None` for invalid/unregistered `FileId`s.
475    #[inline]
476    #[must_use]
477    pub fn file_provenance(
478        &self,
479        id: crate::graph::unified::file::id::FileId,
480    ) -> Option<FileProvenanceView<'_>> {
481        self.files.file_provenance(id)
482    }
483
484    // ------------------------------------------------------------------
485    // Phase 2 binding-plane accessors (P2U03).
486    // ------------------------------------------------------------------
487
488    /// Returns a reference to the scope arena derived during Phase 4e.
489    ///
490    /// The arena is empty on freshly-constructed `CodeGraph` instances and is
491    /// populated by calling `phase4e_binding::derive_binding_plane`.
492    #[inline]
493    #[must_use]
494    pub fn scope_arena(&self) -> &ScopeArena {
495        &self.scope_arena
496    }
497
498    /// Installs a freshly-derived scope arena.
499    ///
500    /// Called from `phase4e_binding::derive_binding_plane` during the build
501    /// pipeline. External callers that run Phase 4e manually (e.g., test
502    /// fixture builders) use this to store the result.
503    pub(crate) fn set_scope_arena(&mut self, arena: ScopeArena) {
504        self.scope_arena = Arc::new(arena);
505    }
506
507    /// Returns a reference to the alias table derived during Phase 4e.
508    ///
509    /// The table is empty on freshly-constructed `CodeGraph` instances and is
510    /// populated by calling `phase4e_binding::derive_binding_plane`.
511    #[inline]
512    #[must_use]
513    pub fn alias_table(&self) -> &AliasTable {
514        &self.alias_table
515    }
516
517    /// Installs a freshly-derived alias table.
518    ///
519    /// Called from `phase4e_binding::derive_binding_plane` during the build
520    /// pipeline. External callers that run Phase 4e manually (e.g., test
521    /// fixture builders) use this to store the result.
522    pub(crate) fn set_alias_table(&mut self, table: AliasTable) {
523        self.alias_table = Arc::new(table);
524    }
525
526    /// Returns a reference to the shadow table derived during Phase 4e.
527    ///
528    /// The table is empty on freshly-constructed `CodeGraph` instances and is
529    /// populated by calling `phase4e_binding::derive_binding_plane`.
530    #[inline]
531    #[must_use]
532    pub fn shadow_table(&self) -> &ShadowTable {
533        &self.shadow_table
534    }
535
536    /// Installs a freshly-derived shadow table.
537    ///
538    /// Called from `phase4e_binding::derive_binding_plane` during the build
539    /// pipeline. External callers that run Phase 4e manually (e.g., test
540    /// fixture builders) use this to store the result.
541    pub(crate) fn set_shadow_table(&mut self, table: ShadowTable) {
542        self.shadow_table = Arc::new(table);
543    }
544
545    /// Returns a reference to the scope provenance store derived during Phase 4e.
546    ///
547    /// The store is empty on freshly-constructed `CodeGraph` instances and is
548    /// populated by calling `phase4e_binding::derive_binding_plane`.
549    #[inline]
550    #[must_use]
551    pub fn scope_provenance_store(&self) -> &ScopeProvenanceStore {
552        &self.scope_provenance_store
553    }
554
555    /// Looks up scope provenance by `ScopeId`.
556    ///
557    /// Returns `None` if the slot is out of range, vacant, or the stored
558    /// generation does not match (stale handle).
559    #[inline]
560    #[must_use]
561    pub fn scope_provenance(&self, id: ScopeId) -> Option<&ScopeProvenance> {
562        self.scope_provenance_store.lookup(id)
563    }
564
565    /// Looks up the live `ScopeId` for a stable scope identity.
566    ///
567    /// Returns `None` if no provenance record is registered for that stable id.
568    /// The reverse index is populated by `insert` during Phase 4e and must be
569    /// rebuilt after V9 deserialization.
570    #[inline]
571    #[must_use]
572    pub fn scope_by_stable_id(&self, stable: ScopeStableId) -> Option<ScopeId> {
573        self.scope_provenance_store.scope_by_stable_id(stable)
574    }
575
576    /// Installs a freshly-derived scope provenance store.
577    ///
578    /// Called from `phase4e_binding::derive_binding_plane` during the build
579    /// pipeline. External callers that run Phase 4e manually (e.g., test
580    /// fixture builders) use this to store the result.
581    pub(crate) fn set_scope_provenance_store(&mut self, store: ScopeProvenanceStore) {
582        self.scope_provenance_store = Arc::new(store);
583    }
584
585    /// Returns a reference to the file segment table.
586    #[inline]
587    #[must_use]
588    pub fn file_segments(&self) -> &FileSegmentTable {
589        &self.file_segments
590    }
591
592    /// Replaces the file segment table.
593    pub(crate) fn set_file_segments(&mut self, table: FileSegmentTable) {
594        self.file_segments = Arc::new(table);
595    }
596
597    /// Installs the C indirect-call side tables loaded from a V11
598    /// snapshot.
599    ///
600    /// `None` clears the slot (used on V10 → V11 upconvert and on non-C
601    /// workspaces). `Some(...)` carries the live tables onto the freshly
602    /// reconstructed `CodeGraph`. Build-pipeline callers that incrementally
603    /// populate the tables should prefer [`Self::c_indirect_tables_mut`].
604    pub(crate) fn set_c_indirect_tables(&mut self, tables: Option<CIndirectSideTables>) {
605        self.c_indirect_tables = tables;
606    }
607
608    /// Returns a mutable reference to the file segment table (via `Arc::make_mut`).
609    pub(crate) fn file_segments_mut(&mut self) -> &mut FileSegmentTable {
610        Arc::make_mut(&mut self.file_segments)
611    }
612
613    /// Test-only helper that records a file segment directly on the
614    /// graph, bypassing the full Phase 3 commit pipeline. Only
615    /// available under `#[cfg(feature = "rebuild-internals")]` so the
616    /// surface is opt-in for rebuild-plane consumers (the feature is
617    /// whitelisted to sqry-daemon + sqry-core integration tests; see
618    /// `sqry-core/tests/rebuild_internals_whitelist.rs`).
619    ///
620    /// Integration tests (notably
621    /// `sqry-core/tests/incremental_remove_file_scale.rs`) call this
622    /// to seed the synthetic workspaces they build before exercising
623    /// `RebuildGraph::remove_file` / `CodeGraph::remove_file`. Production
624    /// code paths never touch this method — Phase 3 parallel commit
625    /// is the sole production writer, via the crate-internal
626    /// `file_segments_mut` accessor above.
627    ///
628    /// Renamed with `test_only_` prefix so the purpose is unambiguous
629    /// at every call site; `#[doc(hidden)]` hides it from rendered
630    /// rustdoc so downstream daemon integrations don't discover it by
631    /// accident.
632    #[cfg(feature = "rebuild-internals")]
633    #[doc(hidden)]
634    pub fn test_only_record_file_segment(
635        &mut self,
636        file_id: FileId,
637        start_slot: u32,
638        slot_count: u32,
639    ) {
640        Arc::make_mut(&mut self.file_segments).record_range(file_id, start_slot, slot_count);
641    }
642
643    /// Sets the provenance stores and fact epoch, typically called by the
644    /// persistence loader after deserializing a V8 snapshot.
645    pub(crate) fn set_provenance(
646        &mut self,
647        node_provenance: NodeProvenanceStore,
648        edge_provenance: EdgeProvenanceStore,
649        fact_epoch: u64,
650    ) {
651        self.node_provenance = Arc::new(node_provenance);
652        self.edge_provenance = Arc::new(edge_provenance);
653        self.fact_epoch = fact_epoch;
654    }
655
656    /// Returns the current epoch.
657    #[inline]
658    #[must_use]
659    pub fn epoch(&self) -> u64 {
660        self.epoch
661    }
662
663    /// Returns a mutable reference to the node arena.
664    ///
665    /// Uses `Arc::make_mut` for copy-on-write semantics: if other
666    /// references exist (e.g., snapshots), the data is cloned.
667    #[inline]
668    pub fn nodes_mut(&mut self) -> &mut NodeArena {
669        Arc::make_mut(&mut self.nodes)
670    }
671
672    /// Returns a mutable reference to the bidirectional edge store.
673    ///
674    /// Uses `Arc::make_mut` for copy-on-write semantics.
675    #[inline]
676    pub fn edges_mut(&mut self) -> &mut BidirectionalEdgeStore {
677        Arc::make_mut(&mut self.edges)
678    }
679
680    /// Returns a mutable reference to the string interner.
681    ///
682    /// Uses `Arc::make_mut` for copy-on-write semantics.
683    #[inline]
684    pub fn strings_mut(&mut self) -> &mut StringInterner {
685        Arc::make_mut(&mut self.strings)
686    }
687
688    /// Returns a mutable reference to the file registry.
689    ///
690    /// Uses `Arc::make_mut` for copy-on-write semantics.
691    #[inline]
692    pub fn files_mut(&mut self) -> &mut FileRegistry {
693        Arc::make_mut(&mut self.files)
694    }
695
696    /// Returns a mutable reference to the auxiliary indices.
697    ///
698    /// Uses `Arc::make_mut` for copy-on-write semantics.
699    #[inline]
700    pub fn indices_mut(&mut self) -> &mut AuxiliaryIndices {
701        Arc::make_mut(&mut self.indices)
702    }
703
704    /// Returns a mutable reference to the macro boundary metadata store.
705    ///
706    /// Uses `Arc::make_mut` for copy-on-write semantics.
707    #[inline]
708    pub fn macro_metadata_mut(&mut self) -> &mut NodeMetadataStore {
709        Arc::make_mut(&mut self.macro_metadata)
710    }
711
712    /// Returns mutable references to both the node arena and the string interner.
713    ///
714    /// This avoids the borrow-conflict that arises when calling `nodes_mut()` and
715    /// `strings_mut()` separately on `&mut self`.
716    #[inline]
717    pub fn nodes_and_strings_mut(&mut self) -> (&mut NodeArena, &mut StringInterner) {
718        (
719            Arc::make_mut(&mut self.nodes),
720            Arc::make_mut(&mut self.strings),
721        )
722    }
723
724    /// Rebuilds auxiliary indices from the current node arena.
725    ///
726    /// This avoids the borrow conflict that arises when calling `nodes()` and
727    /// `indices_mut()` separately on `&mut self`. Uses disjoint field borrowing
728    /// to access `nodes` (shared) and `indices` (mutable) simultaneously.
729    /// Internally calls `AuxiliaryIndices::build_from_arena` which clears
730    /// existing indices and rebuilds in a single pass without per-element
731    /// duplicate checking.
732    ///
733    /// As of Task 4 Step 4 Phase 2 this inherent method delegates to the
734    /// generic
735    /// [`crate::graph::unified::build::parallel_commit::rebuild_indices`]
736    /// free function so the same implementation serves both the
737    /// full-build (`CodeGraph`) and incremental-rebuild (`RebuildGraph`)
738    /// pipelines. Call sites that hold a concrete `CodeGraph` can keep
739    /// using `graph.rebuild_indices()`; incremental rebuild call sites
740    /// should use the free function directly.
741    pub fn rebuild_indices(&mut self) {
742        crate::graph::unified::build::parallel_commit::rebuild_indices(self);
743    }
744
745    /// Increments the epoch counter and returns the new value.
746    ///
747    /// Called automatically by `ConcurrentCodeGraph::write()`.
748    #[inline]
749    pub fn bump_epoch(&mut self) -> u64 {
750        self.epoch = self.epoch.wrapping_add(1);
751        self.epoch
752    }
753
754    /// Sets the epoch to a specific value.
755    ///
756    /// This is primarily for testing or reconstruction from serialized state.
757    #[inline]
758    pub fn set_epoch(&mut self, epoch: u64) {
759        self.epoch = epoch;
760    }
761
762    /// Returns the number of nodes in the graph.
763    ///
764    /// This is a convenience method that delegates to `nodes().len()`.
765    ///
766    /// # Example
767    ///
768    /// ```rust
769    /// use sqry_core::graph::unified::concurrent::CodeGraph;
770    ///
771    /// let graph = CodeGraph::new();
772    /// assert_eq!(graph.node_count(), 0);
773    /// ```
774    #[inline]
775    #[must_use]
776    pub fn node_count(&self) -> usize {
777        self.nodes.len()
778    }
779
780    /// Returns the number of edges in the graph (forward direction).
781    ///
782    /// This counts edges in the forward store, including both CSR and delta edges.
783    ///
784    /// # Example
785    ///
786    /// ```rust
787    /// use sqry_core::graph::unified::concurrent::CodeGraph;
788    ///
789    /// let graph = CodeGraph::new();
790    /// assert_eq!(graph.edge_count(), 0);
791    /// ```
792    #[inline]
793    #[must_use]
794    pub fn edge_count(&self) -> usize {
795        let stats = self.edges.stats();
796        stats.forward.csr_edge_count + stats.forward.delta_edge_count
797    }
798
799    /// Returns true if the graph contains no nodes.
800    ///
801    /// This is a convenience method that delegates to `nodes().is_empty()`.
802    ///
803    /// # Example
804    ///
805    /// ```rust
806    /// use sqry_core::graph::unified::concurrent::CodeGraph;
807    ///
808    /// let graph = CodeGraph::new();
809    /// assert!(graph.is_empty());
810    /// ```
811    #[inline]
812    #[must_use]
813    pub fn is_empty(&self) -> bool {
814        self.nodes.is_empty()
815    }
816
817    /// Returns an iterator over all indexed file paths.
818    ///
819    /// This is useful for enumerating all files that have been processed
820    /// and added to the graph.
821    ///
822    /// # Example
823    ///
824    /// ```rust
825    /// use sqry_core::graph::unified::concurrent::CodeGraph;
826    ///
827    /// let graph = CodeGraph::new();
828    /// for (file_id, path) in graph.indexed_files() {
829    ///     println!("File {}: {}", file_id.index(), path.display());
830    /// }
831    /// ```
832    #[inline]
833    pub fn indexed_files(
834        &self,
835    ) -> impl Iterator<Item = (crate::graph::unified::file::FileId, &std::path::Path)> {
836        self.files
837            .iter()
838            .map(|(id, arc_path)| (id, arc_path.as_ref()))
839    }
840
841    /// Returns the set of files that import one or more symbols exported by
842    /// `file_id`, deduplicated and sorted ascending.
843    ///
844    /// For every [`EdgeKind::Imports`] edge whose target node lives in
845    /// `file_id`, the source node's [`FileId`] is added to the result. Files
846    /// are returned sorted by raw index so the result is deterministic across
847    /// runs. The caller's own file is never included — an `Imports` edge
848    /// whose source and target both live in `file_id` is treated as a
849    /// self-import and elided. Edges whose source node is no longer resolvable
850    /// in the arena (tombstoned) are silently skipped.
851    ///
852    /// This is the file-level view of Pass 4 cross-file `Imports` edges, used
853    /// by the incremental rebuild engine to compute reverse-dependency
854    /// closures: "if file X changes its exports, which files need to be
855    /// re-linked?"
856    ///
857    /// # Complexity
858    ///
859    /// `O(|nodes_in_file_id| × avg_incoming_edges_per_node)` amortized. Uses
860    /// [`AuxiliaryIndices::by_file`] for O(1)-amortized per-file node lookup
861    /// (HashMap-backed) and the bidirectional edge store's reverse adjacency;
862    /// no full-graph scan. A final `O(R log R)` sort over the deduplicated
863    /// importer set (where `R` is the importer count) is negligible in
864    /// practice since `R ≤ file_count`.
865    ///
866    /// [`AuxiliaryIndices::by_file`]: crate::graph::unified::storage::indices::AuxiliaryIndices::by_file
867    #[must_use]
868    pub fn reverse_import_index(&self, file_id: FileId) -> Vec<FileId> {
869        let mut importers: HashSet<FileId> = HashSet::new();
870        for &target_node in self.indices.by_file(file_id) {
871            for edge_ref in self.edges.edges_to(target_node) {
872                if !matches!(edge_ref.kind, EdgeKind::Imports { .. }) {
873                    continue;
874                }
875                let Some(source_entry) = self.nodes.get(edge_ref.source) else {
876                    continue;
877                };
878                let source_file = source_entry.file;
879                if source_file != file_id {
880                    importers.insert(source_file);
881                }
882            }
883        }
884        let mut result: Vec<FileId> = importers.into_iter().collect();
885        result.sort();
886        result
887    }
888
889    /// Returns the set of files that hold at least one live inter-file edge
890    /// targeting a node in `file_id`, deduplicated and sorted ascending.
891    ///
892    /// Unlike [`reverse_import_index`](Self::reverse_import_index) — which
893    /// filters to [`EdgeKind::Imports`] only — this helper treats **every**
894    /// cross-file edge as a dependency signal: `Calls`, `References`,
895    /// `TypeOf`, `Inherits`, `Implements`, `FfiCall`, `HttpRequest`,
896    /// `GrpcCall`, `WebAssemblyCall`, `DbQuery`, `TableRead`, `TableWrite`,
897    /// `TriggeredBy`, `MessageQueue`, `WebSocket`, `GraphQLOperation`,
898    /// `ProcessExec`, `FileIpc`, `ProtocolCall`, and any future
899    /// cross-file-capable variant. This is the reverse-dependency surface the
900    /// incremental rebuild engine (Task 4 Step 4 Phase 3e) needs: when
901    /// `file_id` changes, every file whose committed edges point into
902    /// `file_id`'s nodes must re-enter the rebuild closure so its cross-file
903    /// references survive the target-side tombstone-and-reparse cycle.
904    ///
905    /// The caller's own file is never included — an edge whose source and
906    /// target both live in `file_id` is a self-reference and is elided.
907    /// Edges whose source node is no longer resolvable in the arena
908    /// (tombstoned) are silently skipped.
909    ///
910    /// # When to use this vs [`reverse_import_index`](Self::reverse_import_index)
911    ///
912    /// * [`reverse_import_index`](Self::reverse_import_index) remains the
913    ///   right surface for consumers that specifically need *import*
914    ///   relationships (export surface analysis, module-dependency graphs,
915    ///   etc.).
916    /// * `reverse_dependency_index` is the right surface for incremental
917    ///   rebuild closure computation. Widening past imports is necessary
918    ///   because call sites, type references, trait implementations, FFI
919    ///   declarations, HTTP clients, and every other cross-file edge kind
920    ///   hold a committed edge into the target file that becomes stale the
921    ///   moment `remove_file(target)` tombstones its arena nodes. Leaving
922    ///   those files out of the closure leaves the committed edges pointing
923    ///   at the stale (pre-tombstone) node IDs — Phase 4c-prime only
924    ///   rewrites edges on **re-parsed** files' `PendingEdge` sets, never
925    ///   committed edges owned by files outside the reparse scope.
926    ///
927    /// # Complexity
928    ///
929    /// `O(|nodes_in_file_id| × avg_incoming_edges_per_node)` amortized —
930    /// same bound as [`reverse_import_index`](Self::reverse_import_index).
931    /// Uses [`AuxiliaryIndices::by_file`] for O(1)-amortized per-file node
932    /// lookup and the bidirectional edge store's reverse adjacency; no
933    /// full-graph scan. Final `O(R log R)` sort over the deduplicated
934    /// dependent set is negligible since `R ≤ file_count`.
935    ///
936    /// # Over-widening is expected and acceptable
937    ///
938    /// The closure will include every file that references anything in
939    /// `file_id`, not just files whose exports change. In common codebases
940    /// this widens the reparse set modestly (a 10-file change may expand
941    /// to 20–30 dependent files in a medium crate). Correctness requires
942    /// the widening; minimality is a follow-up optimisation if profiling
943    /// demands it.
944    ///
945    /// [`AuxiliaryIndices::by_file`]: crate::graph::unified::storage::indices::AuxiliaryIndices::by_file
946    #[must_use]
947    pub fn reverse_dependency_index(&self, file_id: FileId) -> Vec<FileId> {
948        let mut dependents: HashSet<FileId> = HashSet::new();
949        for &target_node in self.indices.by_file(file_id) {
950            for edge_ref in self.edges.edges_to(target_node) {
951                let Some(source_entry) = self.nodes.get(edge_ref.source) else {
952                    continue;
953                };
954                let source_file = source_entry.file;
955                if source_file != file_id {
956                    dependents.insert(source_file);
957                }
958            }
959        }
960        let mut result: Vec<FileId> = dependents.into_iter().collect();
961        result.sort();
962        result
963    }
964
965    /// Returns the per-language confidence metadata.
966    ///
967    /// This contains analysis confidence information collected during graph build,
968    /// primarily used by language plugins (e.g., Rust) to track analysis quality.
969    #[inline]
970    #[must_use]
971    pub fn confidence(&self) -> &HashMap<String, ConfidenceMetadata> {
972        &self.confidence
973    }
974
975    /// Merges confidence metadata for a language.
976    ///
977    /// If confidence already exists for the language, this merges the new
978    /// metadata (taking the lower confidence level and combining limitations).
979    /// Otherwise, it inserts the new confidence.
980    pub fn merge_confidence(&mut self, language: &str, metadata: ConfidenceMetadata) {
981        use crate::confidence::ConfidenceLevel;
982
983        self.confidence
984            .entry(language.to_string())
985            .and_modify(|existing| {
986                // Take the lower confidence level (more conservative)
987                let new_level = match (&existing.level, &metadata.level) {
988                    (ConfidenceLevel::Verified, other) | (other, ConfidenceLevel::Verified) => {
989                        *other
990                    }
991                    (ConfidenceLevel::Partial, ConfidenceLevel::AstOnly)
992                    | (ConfidenceLevel::AstOnly, ConfidenceLevel::Partial) => {
993                        ConfidenceLevel::AstOnly
994                    }
995                    (level, _) => *level,
996                };
997                existing.level = new_level;
998
999                // Merge limitations (deduplicated)
1000                for limitation in &metadata.limitations {
1001                    if !existing.limitations.contains(limitation) {
1002                        existing.limitations.push(limitation.clone());
1003                    }
1004                }
1005
1006                // Merge unavailable features (deduplicated)
1007                for feature in &metadata.unavailable_features {
1008                    if !existing.unavailable_features.contains(feature) {
1009                        existing.unavailable_features.push(feature.clone());
1010                    }
1011                }
1012            })
1013            .or_insert(metadata);
1014    }
1015
1016    /// Sets the confidence metadata map directly.
1017    ///
1018    /// This is primarily used when loading a graph from serialized state.
1019    pub fn set_confidence(&mut self, confidence: HashMap<String, ConfidenceMetadata>) {
1020        self.confidence = confidence;
1021    }
1022
1023    // ------------------------------------------------------------------
1024    // Task 4 Step 2 (A2 §F.2) — File-level tombstoning on a CodeGraph.
1025    //
1026    // Unlike the rebuild pipeline's `RebuildGraph::remove_file`, this
1027    // path mutates a live `CodeGraph` in place and is the mechanism
1028    // used by the full-rebuild flow when it needs to evict a file's
1029    // nodes+edges between compactions. The daemon's incremental
1030    // `WorkspaceManager` (Task 6) goes through the rebuild path, which
1031    // is why this entry point is `pub(crate)`.
1032    // ------------------------------------------------------------------
1033
1034    /// Tombstone every node that belongs to `file_id`, invalidate every
1035    /// edge whose source or target is one of those nodes (across both
1036    /// forward and reverse CSR + delta tiers), drop the file's entry
1037    /// from the [`FileRegistry`], and return the set of [`NodeId`]s
1038    /// that were tombstoned.
1039    ///
1040    /// This is the §F.2-aware file-removal primitive. Semantically, the
1041    /// post-condition matches what a full rebuild of the workspace
1042    /// without `file_id` would produce:
1043    ///
1044    /// * Every [`NodeEntry`] whose [`NodeEntry.file`] was `file_id` has
1045    ///   been `NodeArena::remove`d, advancing its slot generation so
1046    ///   stale [`NodeId`] handles do not alias a later re-allocation.
1047    /// * Every CSR edge whose source or target slot matches one of the
1048    ///   tombstoned slot indices has its `csr_tombstones` bit set; the
1049    ///   read path's merge step already filters tombstoned CSR edges
1050    ///   out of every query result.
1051    /// * Every delta-buffer edge (Add or Remove, any file) whose source
1052    ///   or target matches a tombstoned slot has been dropped from the
1053    ///   delta in both directions.
1054    /// * The [`AuxiliaryIndices`] (kind / name / qualified-name / file)
1055    ///   no longer reference any of the tombstoned `NodeId`s.
1056    /// * [`NodeMetadataStore`], [`NodeProvenanceStore`], [`ScopeArena`],
1057    ///   [`AliasTable`], and [`ShadowTable`] have been compacted through
1058    ///   the [`NodeIdBearing::retain_nodes`] predicate so no
1059    ///   tombstoned `NodeId` survives in any publish-visible store.
1060    /// * [`FileRegistry::per_file_nodes`] no longer holds a bucket for
1061    ///   `file_id`, the lookup slot is recycled, and
1062    ///   [`FileRegistry::resolve(file_id)`] returns `None`.
1063    ///
1064    /// Returns the `Vec<NodeId>` of tombstoned nodes. The returned list
1065    /// is useful for downstream housekeeping (e.g., resetting per-file
1066    /// caches keyed by `NodeId`) and for tests that need to assert on the
1067    /// exact membership of the tombstone set.
1068    ///
1069    /// # Idempotency
1070    ///
1071    /// Calling `remove_file` twice with the same `file_id` — or calling
1072    /// it for a `file_id` that was never registered — is a safe no-op.
1073    /// The returned `Vec<NodeId>` is empty on the second call; no
1074    /// arena / edge / index state is mutated (the predicate-based
1075    /// compaction of `NodeIdBearing` surfaces short-circuits when the
1076    /// dead set is empty).
1077    ///
1078    /// # Visibility
1079    ///
1080    /// `pub(crate)` because external callers (Task 6's
1081    /// `WorkspaceManager` on the sqry-daemon side) route through
1082    /// [`super::super::rebuild::rebuild_graph::RebuildGraph::remove_file`]
1083    /// instead. This `CodeGraph`-level variant is used by full-rebuild
1084    /// housekeeping paths inside sqry-core and by the Task 4 Step 4
1085    /// incremental fallback for cases where the caller already has a
1086    /// `&mut CodeGraph` and does not need the clone-and-publish
1087    /// round-trip of [`clone_for_rebuild`](Self::clone_for_rebuild) →
1088    /// [`finalize`](super::super::rebuild::rebuild_graph::RebuildGraph::finalize).
1089    ///
1090    /// # Performance
1091    ///
1092    /// * `O(|tombstoned| + |csr_edges| + |delta_edges|)` amortised.
1093    ///   The CSR walk is linear in total edge count (not per-file),
1094    ///   which is the dominant cost; each row check is O(1) via the
1095    ///   precomputed dead-slot-index `HashSet` in
1096    ///   [`BidirectionalEdgeStore::tombstone_edges_for_nodes`].
1097    /// * Delta filtering is `O(|delta|)` per direction.
1098    /// * Auxiliary-index compaction is `O(|tombstoned|)` amortised
1099    ///   because each of the four indices keys its entries by the
1100    ///   tombstoned `NodeIds` directly.
1101    ///
1102    /// [`NodeEntry`]: crate::graph::unified::storage::arena::NodeEntry
1103    /// [`NodeEntry.file`]: crate::graph::unified::storage::arena::NodeEntry::file
1104    /// [`NodeIdBearing::retain_nodes`]: crate::graph::unified::rebuild::coverage::NodeIdBearing::retain_nodes
1105    // The only live consumer today is the unit-test suite below (the
1106    // full-rebuild housekeeping fallback described above is not yet
1107    // wired), so the gate is `not(test)` rather than the rebuild-path
1108    // form: the lint stays suppressed on both production default and
1109    // `rebuild-internals` builds and fires under `test`, catching the
1110    // method going stale once its callers land. Published in this
1111    // commit so the §F.2 invariant surface can be reviewed in isolation
1112    // per the Gate 0c split contract.
1113    #[cfg_attr(not(test), allow(dead_code))]
1114    pub(crate) fn remove_file(
1115        &mut self,
1116        file_id: FileId,
1117    ) -> Vec<crate::graph::unified::node::NodeId> {
1118        use crate::graph::unified::node::NodeId;
1119        use crate::graph::unified::rebuild::coverage::NodeIdBearing;
1120
1121        // Drain the per-file bucket. For a file that was never
1122        // registered, this returns an empty Vec — the rest of the
1123        // method short-circuits on the `if tombstoned.is_empty()` test
1124        // below so we still deregister the file on the off chance the
1125        // bucket was empty but the file was registered (defensive; the
1126        // common case is the bucket existed iff the file was
1127        // registered).
1128        let tombstoned: Vec<NodeId> = self.files_mut().take_nodes(file_id);
1129        // Always drop the file's path entry + recycle its slot, even
1130        // when the bucket was empty, so repeated registrations of the
1131        // same path don't resurrect a zombie FileId. `unregister` is
1132        // idempotent (returns None for unknown IDs) so the cost is a
1133        // single HashMap probe when file_id is already gone.
1134        self.files_mut().unregister(file_id);
1135        // Clear the file's segment entry unconditionally (idempotent
1136        // — `FileSegmentTable::remove` no-ops on unknown ids). This
1137        // MUST run before `FileRegistry::unregister` recycles the
1138        // FileId slot for reuse, otherwise a later registration of a
1139        // different path under the reused FileId would inherit the
1140        // previous file's stale node range (see
1141        // `sqry-core/src/graph/unified/build/reindex.rs` — which
1142        // trusts `file_segments().get(file_id)` to decide which slots
1143        // to tombstone). Note: `unregister` above was called first
1144        // only to keep the existing bucket-drain ordering; the
1145        // segment-clear is order-independent with respect to
1146        // `unregister` because neither touches the other's backing
1147        // store, and the FileId slot cannot be recycled-and-reissued
1148        // across a single `remove_file` call (the registry's slot
1149        // recycler is driven by a later `register`, not by
1150        // `unregister`).
1151        self.file_segments_mut().remove(file_id);
1152
1153        if tombstoned.is_empty() {
1154            return tombstoned;
1155        }
1156
1157        // Dead set keyed on NodeId for NodeIdBearing predicates.
1158        // `retain_nodes` uses `HashSet::contains` so membership is O(1).
1159        let dead: HashSet<NodeId> = tombstoned.iter().copied().collect();
1160
1161        // 1. Tombstone the arena slots. `NodeArena::remove` is
1162        //    idempotent — stale NodeIds that don't match a slot's
1163        //    current generation are no-ops, which lets this method be
1164        //    safely re-run on the same file.
1165        {
1166            let arena = self.nodes_mut();
1167            for &nid in &tombstoned {
1168                let _ = arena.remove(nid);
1169            }
1170        }
1171
1172        // 2. Invalidate edges across both CSR + delta in both
1173        //    directions. This is the expensive step; the helper uses a
1174        //    precomputed dead-slot-index set so the CSR walk is linear
1175        //    in total edge count, not quadratic.
1176        self.edges_mut().tombstone_edges_for_nodes(&dead);
1177
1178        // 3. Compact the auxiliary indices so name/kind/qname/file
1179        //    lookups do not return tombstoned NodeIds. Using the
1180        //    NodeIdBearing surface keeps this step in lockstep with the
1181        //    rebuild pipeline's step 4 — any future publish-visible
1182        //    NodeId-bearing container added to the K.A/K.B matrix is
1183        //    automatically swept here too.
1184        {
1185            let predicate: Box<dyn Fn(NodeId) -> bool + '_> = Box::new(|nid| !dead.contains(&nid));
1186            self.indices_mut().retain_nodes(&*predicate);
1187            self.macro_metadata_mut().retain_nodes(&*predicate);
1188            // The remaining K.A rows (node_provenance, scope_arena,
1189            // alias_table, shadow_table) need Arc::make_mut accessors —
1190            // they are wrapped in Arc at rest so this is where the
1191            // CoW clone happens on demand. Inline the Arc::make_mut
1192            // calls here (no public mut accessor exists today because
1193            // the sole writer has been the rebuild path; extend the
1194            // set by adding a similar inline call plus a K.A/K.B row
1195            // in `super::super::rebuild::coverage`).
1196            Arc::make_mut(&mut self.node_provenance).retain_nodes(&*predicate);
1197            Arc::make_mut(&mut self.scope_arena).retain_nodes(&*predicate);
1198            Arc::make_mut(&mut self.alias_table).retain_nodes(&*predicate);
1199            Arc::make_mut(&mut self.shadow_table).retain_nodes(&*predicate);
1200        }
1201
1202        tombstoned
1203    }
1204
1205    // ------------------------------------------------------------------
1206    // Gate 0c (A2 §H, Task 4) — RebuildGraph assembly path.
1207    // ------------------------------------------------------------------
1208
1209    /// Assemble a [`CodeGraph`] from owned rebuild-local parts produced
1210    /// by `RebuildGraph::finalize()` (defined in
1211    /// `super::super::rebuild::rebuild_graph`).
1212    ///
1213    /// This constructor is deliberately `pub(crate)` and named with a
1214    /// leading `__` so it is inaccessible from downstream crates even
1215    /// when the `rebuild-internals` feature is enabled: only code in
1216    /// `sqry-core` itself (specifically `RebuildGraph::finalize`) is
1217    /// permitted to call it. The trybuild fixture
1218    /// `sqry-core/tests/rebuild_internals_compile_fail/rebuild_graph_no_public_assembly.rs`
1219    /// proves there is no other path from `RebuildGraph` to
1220    /// `Arc<CodeGraph>`.
1221    ///
1222    /// The argument order mirrors the `CodeGraph` struct declaration
1223    /// order exactly; the public `clone_for_rebuild` → `finalize`
1224    /// round-trip uses the same macro-driven field enumeration so any
1225    /// new `CodeGraph` field automatically threads through this
1226    /// constructor as well.
1227    #[doc(hidden)]
1228    #[allow(clippy::too_many_arguments)]
1229    #[must_use]
1230    pub(crate) fn __assemble_from_rebuild_parts_internal(
1231        nodes: NodeArena,
1232        edges: BidirectionalEdgeStore,
1233        strings: StringInterner,
1234        files: FileRegistry,
1235        indices: AuxiliaryIndices,
1236        macro_metadata: NodeMetadataStore,
1237        node_provenance: NodeProvenanceStore,
1238        edge_provenance: EdgeProvenanceStore,
1239        fact_epoch: u64,
1240        epoch: u64,
1241        confidence: HashMap<String, ConfidenceMetadata>,
1242        scope_arena: ScopeArena,
1243        alias_table: AliasTable,
1244        shadow_table: ShadowTable,
1245        scope_provenance_store: ScopeProvenanceStore,
1246        file_segments: FileSegmentTable,
1247        go_hints: crate::graph::unified::build::staging::GoHints,
1248    ) -> Self {
1249        Self {
1250            nodes: Arc::new(nodes),
1251            edges: Arc::new(edges),
1252            strings: Arc::new(strings),
1253            files: Arc::new(files),
1254            indices: Arc::new(indices),
1255            macro_metadata: Arc::new(macro_metadata),
1256            node_provenance: Arc::new(node_provenance),
1257            edge_provenance: Arc::new(edge_provenance),
1258            fact_epoch,
1259            epoch,
1260            confidence,
1261            scope_arena: Arc::new(scope_arena),
1262            alias_table: Arc::new(alias_table),
1263            shadow_table: Arc::new(shadow_table),
1264            scope_provenance_store: Arc::new(scope_provenance_store),
1265            file_segments: Arc::new(file_segments),
1266            c_indirect_tables: None,
1267            go_hints,
1268            // A rebuild reparses source files, regenerating is_definition fresh,
1269            // so the reassembled graph always carries genuine signal.
1270            definition_signal_present: true,
1271            // A rebuild reparses source files, reclassifying imports fresh, so
1272            // the reassembled graph always carries genuine signal.
1273            import_classification_signal_present: true,
1274        }
1275    }
1276
1277    // ------------------------------------------------------------------
1278    // Gate 0c (A2 §F) — publish-boundary debug invariants.
1279    //
1280    // These checks fire only in debug / test builds; release builds
1281    // compile them out. They are called from
1282    // `RebuildGraph::finalize()` steps 13 and 14 (the single source of
1283    // truth for the residue check, per §F and §H agreement). Gate 0d
1284    // will additionally wire the bijection check into
1285    // `build_and_persist_graph`, `WorkspaceManager::publish_graph`, and
1286    // every §E equivalence-harness run.
1287    // ------------------------------------------------------------------
1288
1289    /// Assert the bijective bucket-membership invariant (A2 §F.1).
1290    ///
1291    /// Four conditions must hold simultaneously:
1292    /// (a) every `NodeId` inside any `per_file_nodes` bucket maps to a
1293    ///     live arena slot;
1294    /// (b) every `NodeId` appears in exactly one bucket (no duplicates
1295    ///     across buckets, no duplicates within a bucket);
1296    /// (c) the bucket's `FileId` matches the node's own `file` field on
1297    ///     `NodeEntry`;
1298    /// (d) when at least one bucket is populated, every live node in
1299    ///     the arena is accounted for by some bucket.
1300    ///
1301    /// Condition (d) is guarded on `!seen.is_empty()` so an empty-graph
1302    /// (no recorded buckets) publish boundary is vacuously consistent:
1303    /// legacy V7 snapshots, fresh `CodeGraph::new()` instances, and
1304    /// rebuilds on graphs that predate Gate 0c's parallel-commit
1305    /// bucketing must not panic. Once any bucket is populated, every
1306    /// live arena slot must appear in a bucket.
1307    ///
1308    /// Iter-2 B2 (verbatim): this check used to be documented as
1309    /// "vacuous until future `per_file_nodes` work lands". Pulling
1310    /// base-plan Step 1 into Gate 0c retires that phrasing — the check
1311    /// is non-vacuous the moment parallel-parse commits nodes, which
1312    /// happens on every real build. The `!seen.is_empty()` guard now
1313    /// exists solely for the empty-graph corner case, not as a phased-
1314    /// delivery deferral.
1315    ///
1316    /// The check is a no-op in release builds. Panics with a
1317    /// descriptive message on violation. This is intentional: publish-
1318    /// boundary violations are programmer errors that must surface
1319    /// loudly during CI / test runs.
1320    ///
1321    /// # Panics
1322    ///
1323    /// Panics in debug/test builds when bucket membership is duplicated, points
1324    /// at a dead node, assigns a node to the wrong file, or omits a live node
1325    /// after buckets have been populated.
1326    #[cfg(any(debug_assertions, test))]
1327    pub fn assert_bucket_bijection(&self) {
1328        use std::collections::HashMap as StdHashMap;
1329        // (a) + (b) + (c): every bucketed node is live, unique across
1330        // buckets, and the bucket's FileId matches the node's own file.
1331        let mut seen: StdHashMap<
1332            crate::graph::unified::node::NodeId,
1333            crate::graph::unified::file::FileId,
1334        > = StdHashMap::new();
1335        let mut any_bucket_populated = false;
1336        for (file_id, bucket) in self.files.per_file_nodes_for_gate0d() {
1337            if !bucket.is_empty() {
1338                any_bucket_populated = true;
1339            }
1340            // Local dedup guard within the bucket itself. `retain_nodes_in_buckets`
1341            // dedups during finalize step 6, but we re-check here so the
1342            // invariant covers non-finalize publish paths too (e.g. if a
1343            // future pipeline builds a graph without routing through
1344            // `RebuildGraph::finalize`).
1345            let mut within_bucket: std::collections::HashSet<crate::graph::unified::node::NodeId> =
1346                std::collections::HashSet::new();
1347            for node_id in bucket {
1348                assert!(
1349                    within_bucket.insert(node_id),
1350                    "assert_bucket_bijection: duplicate node {node_id:?} inside bucket {file_id:?}"
1351                );
1352                assert!(
1353                    self.nodes.get(node_id).is_some(),
1354                    "assert_bucket_bijection: dead node {node_id:?} in bucket {file_id:?}"
1355                );
1356                let prior = seen.insert(node_id, file_id);
1357                assert!(
1358                    prior.is_none(),
1359                    "assert_bucket_bijection: node {node_id:?} in multiple buckets: \
1360                     prior={prior:?}, current={file_id:?}"
1361                );
1362                if let Some(entry) = self.nodes.get(node_id) {
1363                    assert_eq!(
1364                        entry.file, file_id,
1365                        "assert_bucket_bijection: node {node_id:?} misfiled: in bucket \
1366                         {file_id:?}, actual {:?}",
1367                        entry.file
1368                    );
1369                }
1370            }
1371        }
1372        // (d): every live node is accounted for by `seen` once buckets
1373        // are populated. The guard keeps legacy-empty-graph boundaries
1374        // vacuously consistent (see docs above).
1375        if any_bucket_populated {
1376            for (node_id, _entry) in self.nodes.iter() {
1377                assert!(
1378                    seen.contains_key(&node_id),
1379                    "assert_bucket_bijection: live node {node_id:?} absent from all buckets"
1380                );
1381            }
1382        }
1383    }
1384
1385    /// Assert the pre-reuse tombstone-residue invariant (A2 §F.2).
1386    ///
1387    /// Iterates every publish-visible NodeId-bearing structure on
1388    /// `self` and panics if any contains a node in `dead`. Called from
1389    /// `RebuildGraph::finalize()` step 14 against the set drained at
1390    /// step 8 — exactly one site per the plan's §F / §H agreement.
1391    ///
1392    /// No-op when `dead` is empty or in release builds.
1393    ///
1394    /// # Panics
1395    ///
1396    /// Panics in debug/test builds if any publish-visible node-bearing
1397    /// structure still references a tombstoned node.
1398    #[cfg(any(debug_assertions, test))]
1399    pub fn assert_no_tombstone_residue_for<S: std::hash::BuildHasher>(
1400        &self,
1401        dead: &std::collections::HashSet<crate::graph::unified::node::NodeId, S>,
1402    ) {
1403        use super::super::rebuild::coverage::NodeIdBearing;
1404        if dead.is_empty() {
1405            return;
1406        }
1407        // Every K.A/K.B row must be inspected per §F.2.
1408        for nid in self.nodes.all_node_ids() {
1409            assert!(
1410                !dead.contains(&nid),
1411                "assert_no_tombstone_residue: tombstone {nid:?} still in NodeArena"
1412            );
1413        }
1414        for nid in self.indices.all_node_ids() {
1415            assert!(
1416                !dead.contains(&nid),
1417                "assert_no_tombstone_residue: tombstone {nid:?} still in auxiliary indices"
1418            );
1419        }
1420        for nid in self.edges.all_node_ids() {
1421            assert!(
1422                !dead.contains(&nid),
1423                "assert_no_tombstone_residue: tombstone {nid:?} still in edge store"
1424            );
1425        }
1426        for nid in self.macro_metadata.all_node_ids() {
1427            assert!(
1428                !dead.contains(&nid),
1429                "assert_no_tombstone_residue: tombstone {nid:?} still in macro metadata"
1430            );
1431        }
1432        for nid in self.node_provenance.all_node_ids() {
1433            assert!(
1434                !dead.contains(&nid),
1435                "assert_no_tombstone_residue: tombstone {nid:?} still in node provenance"
1436            );
1437        }
1438        for nid in self.scope_arena.all_node_ids() {
1439            assert!(
1440                !dead.contains(&nid),
1441                "assert_no_tombstone_residue: tombstone {nid:?} still in scope arena"
1442            );
1443        }
1444        for nid in self.alias_table.all_node_ids() {
1445            assert!(
1446                !dead.contains(&nid),
1447                "assert_no_tombstone_residue: tombstone {nid:?} still in alias table"
1448            );
1449        }
1450        for nid in self.shadow_table.all_node_ids() {
1451            assert!(
1452                !dead.contains(&nid),
1453                "assert_no_tombstone_residue: tombstone {nid:?} still in shadow table"
1454            );
1455        }
1456        for nid in self.files.all_node_ids() {
1457            assert!(
1458                !dead.contains(&nid),
1459                "assert_no_tombstone_residue: tombstone {nid:?} still in per-file bucket"
1460            );
1461        }
1462    }
1463}
1464
1465impl Default for CodeGraph {
1466    fn default() -> Self {
1467        Self::new()
1468    }
1469}
1470
1471impl fmt::Debug for CodeGraph {
1472    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1473        f.debug_struct("CodeGraph")
1474            .field("nodes", &self.nodes.len())
1475            .field("epoch", &self.epoch)
1476            .finish_non_exhaustive()
1477    }
1478}
1479
1480impl GraphMemorySize for CodeGraph {
1481    /// Estimates the total heap bytes owned by this `CodeGraph`.
1482    ///
1483    /// Sums heap usage across every component the graph owns: node arena,
1484    /// bidirectional edge store (forward + reverse CSR + tombstones + delta
1485    /// buffer), string interner, file registry, auxiliary indices, sparse
1486    /// macro/classpath metadata, provenance stores, and the per-language
1487    /// confidence map. Used by the `sqryd` daemon's admission controller
1488    /// and workspace retention reaper to enforce memory budgets.
1489    fn heap_bytes(&self) -> usize {
1490        let mut confidence_bytes = self.confidence.capacity()
1491            * (std::mem::size_of::<String>()
1492                + std::mem::size_of::<ConfidenceMetadata>()
1493                + HASHMAP_ENTRY_OVERHEAD);
1494        for (key, meta) in &self.confidence {
1495            confidence_bytes += key.capacity();
1496            // `ConfidenceMetadata.limitations` / `unavailable_features` are
1497            // `Vec<String>`; charge their spill and inner String payloads.
1498            confidence_bytes += meta.limitations.capacity() * std::mem::size_of::<String>();
1499            for s in &meta.limitations {
1500                confidence_bytes += s.capacity();
1501            }
1502            confidence_bytes +=
1503                meta.unavailable_features.capacity() * std::mem::size_of::<String>();
1504            for s in &meta.unavailable_features {
1505                confidence_bytes += s.capacity();
1506            }
1507        }
1508
1509        self.nodes.heap_bytes()
1510            + self.edges.heap_bytes()
1511            + self.strings.heap_bytes()
1512            + self.files.heap_bytes()
1513            + self.indices.heap_bytes()
1514            + self.macro_metadata.heap_bytes()
1515            + self.node_provenance.heap_bytes()
1516            + self.edge_provenance.heap_bytes()
1517            + confidence_bytes
1518            + self.file_segments.capacity()
1519                * std::mem::size_of::<Option<crate::graph::unified::storage::segment::FileSegment>>(
1520                )
1521    }
1522}
1523
1524/// Thread-safe wrapper for `CodeGraph` with epoch versioning.
1525///
1526/// `ConcurrentCodeGraph` provides MVCC-style concurrency:
1527/// - Multiple readers can access the graph simultaneously
1528/// - Only one writer can hold the lock at a time
1529/// - Each write operation increments the epoch for cursor invalidation
1530///
1531/// # Design
1532///
1533/// The wrapper uses `parking_lot::RwLock` for efficient locking:
1534/// - Fair scheduling prevents writer starvation
1535/// - No poisoning (unlike `std::sync::RwLock`)
1536/// - Faster lock/unlock operations
1537///
1538/// # Phase 2 binding-plane access
1539///
1540/// Use the three-line snapshot pattern to access `BindingPlane`:
1541///
1542/// ```rust,ignore
1543/// let read_guard = concurrent.read();
1544/// let snapshot = read_guard.snapshot();
1545/// let plane = snapshot.binding_plane();
1546/// let resolution = plane.resolve(&query);
1547/// ```
1548///
1549/// The explicit snapshot handle makes the MVCC lifetime visible at the call
1550/// site. The full Phase 2 scope/alias/shadow and witness-bearing resolution
1551/// API is exposed through `BindingPlane`.
1552///
1553/// # Usage
1554///
1555/// ```rust
1556/// use sqry_core::graph::unified::concurrent::ConcurrentCodeGraph;
1557///
1558/// let graph = ConcurrentCodeGraph::new();
1559///
1560/// // Read access (multiple readers allowed)
1561/// {
1562///     let guard = graph.read();
1563///     let _nodes = guard.nodes();
1564/// }
1565///
1566/// // Write access (exclusive)
1567/// {
1568///     let mut guard = graph.write();
1569///     let _nodes = guard.nodes_mut();
1570/// }
1571///
1572/// // Snapshot for long queries
1573/// let snapshot = graph.snapshot();
1574/// ```
1575pub struct ConcurrentCodeGraph {
1576    /// The underlying code graph protected by a read-write lock.
1577    inner: RwLock<CodeGraph>,
1578    /// Global epoch counter for cursor validation.
1579    epoch: AtomicU64,
1580}
1581
1582impl ConcurrentCodeGraph {
1583    /// Creates a new empty `ConcurrentCodeGraph`.
1584    #[must_use]
1585    pub fn new() -> Self {
1586        Self {
1587            inner: RwLock::new(CodeGraph::new()),
1588            epoch: AtomicU64::new(0),
1589        }
1590    }
1591
1592    /// Creates a `ConcurrentCodeGraph` from an existing `CodeGraph`.
1593    #[must_use]
1594    pub fn from_graph(graph: CodeGraph) -> Self {
1595        let epoch = graph.epoch();
1596        Self {
1597            inner: RwLock::new(graph),
1598            epoch: AtomicU64::new(epoch),
1599        }
1600    }
1601
1602    /// Acquires a read lock on the graph.
1603    ///
1604    /// Multiple readers can hold the lock simultaneously.
1605    /// This does not increment the epoch.
1606    #[inline]
1607    pub fn read(&self) -> RwLockReadGuard<'_, CodeGraph> {
1608        self.inner.read()
1609    }
1610
1611    /// Acquires a write lock on the graph.
1612    ///
1613    /// Only one writer can hold the lock at a time.
1614    /// This increments the global epoch counter.
1615    #[inline]
1616    pub fn write(&self) -> RwLockWriteGuard<'_, CodeGraph> {
1617        // Increment the global epoch
1618        self.epoch.fetch_add(1, Ordering::SeqCst);
1619        let mut guard = self.inner.write();
1620        // Sync the inner graph's epoch with the global epoch
1621        guard.set_epoch(self.epoch.load(Ordering::SeqCst));
1622        guard
1623    }
1624
1625    /// Returns the current global epoch.
1626    ///
1627    /// This can be used to detect if the graph has been modified
1628    /// since a previous operation (cursor invalidation).
1629    #[inline]
1630    #[must_use]
1631    pub fn epoch(&self) -> u64 {
1632        self.epoch.load(Ordering::SeqCst)
1633    }
1634
1635    /// Creates a cheap snapshot of the graph.
1636    ///
1637    /// This acquires a brief read lock to clone the Arc references.
1638    /// The snapshot is isolated from future mutations.
1639    #[must_use]
1640    pub fn snapshot(&self) -> GraphSnapshot {
1641        self.inner.read().snapshot()
1642    }
1643
1644    // ------------------------------------------------------------------
1645    // Phase 1 fact-layer provenance convenience accessors.
1646    // These acquire a brief read lock, matching the snapshot() pattern.
1647    // ------------------------------------------------------------------
1648
1649    /// Returns the monotonic fact-layer epoch from the underlying graph.
1650    #[must_use]
1651    pub fn fact_epoch(&self) -> u64 {
1652        self.inner.read().fact_epoch()
1653    }
1654
1655    /// Looks up node provenance by `NodeId` (acquires a brief read lock).
1656    #[must_use]
1657    pub fn node_provenance(
1658        &self,
1659        id: crate::graph::unified::node::id::NodeId,
1660    ) -> Option<NodeProvenance> {
1661        self.inner.read().node_provenance(id).copied()
1662    }
1663
1664    /// Looks up edge provenance by `EdgeId` (acquires a brief read lock).
1665    #[must_use]
1666    pub fn edge_provenance(
1667        &self,
1668        id: crate::graph::unified::edge::id::EdgeId,
1669    ) -> Option<EdgeProvenance> {
1670        self.inner.read().edge_provenance(id).copied()
1671    }
1672
1673    /// Returns a file provenance view (acquires a brief read lock).
1674    ///
1675    /// Returns an owned copy since the borrow cannot outlive the lock guard.
1676    #[must_use]
1677    pub fn file_provenance(
1678        &self,
1679        id: crate::graph::unified::file::id::FileId,
1680    ) -> Option<OwnedFileProvenanceView> {
1681        let guard = self.inner.read();
1682        guard.file_provenance(id).map(|v| OwnedFileProvenanceView {
1683            content_hash: *v.content_hash,
1684            indexed_at: v.indexed_at,
1685            source_uri: v.source_uri,
1686            is_external: v.is_external,
1687        })
1688    }
1689
1690    // ------------------------------------------------------------------
1691    // Phase 2 binding-plane accessors (P2U03).
1692    // ------------------------------------------------------------------
1693
1694    /// Returns the scope arena from the underlying graph (acquires a brief
1695    /// read lock).
1696    ///
1697    /// Returns an `Arc` clone so the caller does not hold the lock beyond
1698    /// this call site.
1699    #[must_use]
1700    pub fn scope_arena(&self) -> Arc<ScopeArena> {
1701        Arc::clone(&self.inner.read().scope_arena)
1702    }
1703
1704    /// Returns the alias table from the underlying graph (acquires a brief
1705    /// read lock).
1706    ///
1707    /// Returns an `Arc` clone so the caller does not hold the lock beyond
1708    /// this call site.
1709    #[must_use]
1710    pub fn alias_table(&self) -> Arc<AliasTable> {
1711        Arc::clone(&self.inner.read().alias_table)
1712    }
1713
1714    /// Returns the shadow table from the underlying graph (acquires a brief
1715    /// read lock).
1716    ///
1717    /// Returns an `Arc` clone so the caller does not hold the lock beyond
1718    /// this call site.
1719    #[must_use]
1720    pub fn shadow_table(&self) -> Arc<ShadowTable> {
1721        Arc::clone(&self.inner.read().shadow_table)
1722    }
1723
1724    /// Returns the scope provenance store from the underlying graph (acquires
1725    /// a brief read lock).
1726    ///
1727    /// Returns an `Arc` clone so the caller does not hold the lock beyond
1728    /// this call site.
1729    #[must_use]
1730    pub fn scope_provenance_store(&self) -> Arc<ScopeProvenanceStore> {
1731        Arc::clone(&self.inner.read().scope_provenance_store)
1732    }
1733
1734    /// Looks up scope provenance by `ScopeId` (acquires a brief read lock).
1735    ///
1736    /// Returns an owned copy since the borrow cannot outlive the lock guard.
1737    #[must_use]
1738    pub fn scope_provenance(&self, id: ScopeId) -> Option<ScopeProvenance> {
1739        self.inner.read().scope_provenance(id).cloned()
1740    }
1741
1742    /// Looks up the live `ScopeId` for a stable scope identity (acquires a
1743    /// brief read lock).
1744    ///
1745    /// Returns `None` if no provenance record is registered for that stable id.
1746    #[must_use]
1747    pub fn scope_by_stable_id(&self, stable: ScopeStableId) -> Option<ScopeId> {
1748        self.inner.read().scope_by_stable_id(stable)
1749    }
1750
1751    /// Returns the file segment table from the underlying graph (acquires a
1752    /// brief read lock).
1753    #[must_use]
1754    pub fn file_segments(&self) -> Arc<FileSegmentTable> {
1755        Arc::clone(&self.inner.read().file_segments)
1756    }
1757
1758    /// Attempts to acquire a read lock without blocking.
1759    ///
1760    /// Returns `None` if the lock is currently held exclusively.
1761    #[inline]
1762    #[must_use]
1763    pub fn try_read(&self) -> Option<RwLockReadGuard<'_, CodeGraph>> {
1764        self.inner.try_read()
1765    }
1766
1767    /// Attempts to acquire a write lock without blocking.
1768    ///
1769    /// Returns `None` if the lock is currently held by another thread.
1770    /// If successful, increments the epoch.
1771    #[inline]
1772    pub fn try_write(&self) -> Option<RwLockWriteGuard<'_, CodeGraph>> {
1773        self.inner.try_write().map(|mut guard| {
1774            self.epoch.fetch_add(1, Ordering::SeqCst);
1775            guard.set_epoch(self.epoch.load(Ordering::SeqCst));
1776            guard
1777        })
1778    }
1779}
1780
1781impl Default for ConcurrentCodeGraph {
1782    fn default() -> Self {
1783        Self::new()
1784    }
1785}
1786
1787impl fmt::Debug for ConcurrentCodeGraph {
1788    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1789        f.debug_struct("ConcurrentCodeGraph")
1790            .field("epoch", &self.epoch.load(Ordering::SeqCst))
1791            .finish_non_exhaustive()
1792    }
1793}
1794
1795/// Owned copy of file provenance, returned by [`ConcurrentCodeGraph::file_provenance`]
1796/// because the borrowed [`FileProvenanceView`] cannot outlive the read-lock guard.
1797#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1798pub struct OwnedFileProvenanceView {
1799    /// SHA-256 of the on-disk file bytes (owned copy).
1800    pub content_hash: [u8; 32],
1801    /// Unix-epoch seconds at which this file was registered.
1802    pub indexed_at: u64,
1803    /// Optional interned physical origin URI.
1804    pub source_uri: Option<StringId>,
1805    /// Whether this file originates from an external source.
1806    pub is_external: bool,
1807}
1808
1809/// Immutable snapshot of a `CodeGraph` for long-running queries.
1810///
1811/// `GraphSnapshot` holds Arc references to the graph components,
1812/// providing a consistent view that is isolated from concurrent mutations.
1813///
1814/// # Design
1815///
1816/// Snapshots are created via `CodeGraph::snapshot()` or
1817/// `ConcurrentCodeGraph::snapshot()`. They are:
1818///
1819/// - **Immutable**: No mutation methods available
1820/// - **Isolated**: Independent of future graph mutations
1821/// - **Cheap**: Only Arc clones, no data copying
1822/// - **Self-contained**: Can outlive the original graph/lock
1823///
1824/// # Usage
1825///
1826/// ```rust
1827/// use sqry_core::graph::unified::concurrent::{ConcurrentCodeGraph, GraphSnapshot};
1828///
1829/// let graph = ConcurrentCodeGraph::new();
1830///
1831/// // Create snapshot for a long query
1832/// let snapshot: GraphSnapshot = graph.snapshot();
1833///
1834/// // Snapshot can be used independently
1835/// let _epoch = snapshot.epoch();
1836/// ```
1837#[derive(Clone)]
1838pub struct GraphSnapshot {
1839    /// Node storage snapshot.
1840    nodes: Arc<NodeArena>,
1841    /// Edge storage snapshot.
1842    edges: Arc<BidirectionalEdgeStore>,
1843    /// String interner snapshot.
1844    strings: Arc<StringInterner>,
1845    /// File registry snapshot.
1846    files: Arc<FileRegistry>,
1847    /// Auxiliary indices snapshot.
1848    indices: Arc<AuxiliaryIndices>,
1849    /// Sparse macro boundary metadata snapshot.
1850    macro_metadata: Arc<NodeMetadataStore>,
1851    /// Dense node provenance snapshot (Phase 1).
1852    node_provenance: Arc<NodeProvenanceStore>,
1853    /// Dense edge provenance snapshot (Phase 1).
1854    edge_provenance: Arc<EdgeProvenanceStore>,
1855    /// Monotonic fact-layer epoch at snapshot time.
1856    fact_epoch: u64,
1857    /// Epoch at snapshot time (for cursor validation).
1858    epoch: u64,
1859    /// Phase 2 binding-plane scope arena snapshot (populated by Phase 4e).
1860    scope_arena: Arc<ScopeArena>,
1861    /// Phase 2 binding-plane alias table snapshot (populated by Phase 4e / P2U04).
1862    alias_table: Arc<AliasTable>,
1863    /// Phase 2 binding-plane shadow table snapshot (populated by Phase 4e / P2U05).
1864    shadow_table: Arc<ShadowTable>,
1865    /// Phase 2 binding-plane scope provenance store snapshot (populated by Phase 4e / P2U11).
1866    scope_provenance_store: Arc<ScopeProvenanceStore>,
1867    /// Phase 3 file segment table snapshot mapping `FileId` to node ranges.
1868    file_segments: Arc<FileSegmentTable>,
1869    /// Phase A (U09): C indirect-call resolver side tables snapshot.
1870    ///
1871    /// `None` on non-C workspaces. The snapshot owns its own clone of the
1872    /// `Option<CIndirectSideTables>` so concurrent readers see a stable
1873    /// view independent of subsequent mutations to the source `CodeGraph`.
1874    c_indirect_tables: Option<CIndirectSideTables>,
1875    /// Whether this snapshot carries genuine `NodeEntry.is_definition` signal
1876    /// (R3 marker). Copied from the source [`CodeGraph`] at snapshot time. See
1877    /// [`CodeGraph::definition_signal_present`] for the contract.
1878    definition_signal_present: bool,
1879    /// Whether this snapshot carries genuine import-classification signal
1880    /// (issue #467). Copied from the source [`CodeGraph`] at snapshot time. See
1881    /// [`CodeGraph::import_classification_signal_present`] for the contract.
1882    import_classification_signal_present: bool,
1883}
1884
1885impl GraphSnapshot {
1886    /// Returns whether this snapshot carries genuine `is_definition` signal.
1887    ///
1888    /// See [`CodeGraph::definition_signal_present`] for the full contract.
1889    #[inline]
1890    #[must_use]
1891    pub fn definition_signal_present(&self) -> bool {
1892        self.definition_signal_present
1893    }
1894
1895    /// Returns whether this snapshot carries genuine import-classification signal.
1896    ///
1897    /// See [`CodeGraph::import_classification_signal_present`] for the full contract.
1898    #[inline]
1899    #[must_use]
1900    pub fn import_classification_signal_present(&self) -> bool {
1901        self.import_classification_signal_present
1902    }
1903
1904    /// Returns a reference to the node arena.
1905    #[inline]
1906    #[must_use]
1907    pub fn nodes(&self) -> &NodeArena {
1908        &self.nodes
1909    }
1910
1911    /// Returns a reference to the bidirectional edge store.
1912    #[inline]
1913    #[must_use]
1914    pub fn edges(&self) -> &BidirectionalEdgeStore {
1915        &self.edges
1916    }
1917
1918    /// Returns a reference to the string interner.
1919    #[inline]
1920    #[must_use]
1921    pub fn strings(&self) -> &StringInterner {
1922        &self.strings
1923    }
1924
1925    /// Returns a reference to the file registry.
1926    #[inline]
1927    #[must_use]
1928    pub fn files(&self) -> &FileRegistry {
1929        &self.files
1930    }
1931
1932    /// Returns a reference to the auxiliary indices.
1933    #[inline]
1934    #[must_use]
1935    pub fn indices(&self) -> &AuxiliaryIndices {
1936        &self.indices
1937    }
1938
1939    /// Returns a reference to the macro boundary metadata store.
1940    #[inline]
1941    #[must_use]
1942    pub fn macro_metadata(&self) -> &NodeMetadataStore {
1943        &self.macro_metadata
1944    }
1945
1946    /// Returns a reference to the C indirect-call side tables, if any.
1947    ///
1948    /// Mirrors [`CodeGraph::c_indirect_tables`]; see that method for
1949    /// the contract. Snapshot-level access is read-only.
1950    #[inline]
1951    #[must_use]
1952    pub fn c_indirect_tables(&self) -> Option<&CIndirectSideTables> {
1953        self.c_indirect_tables.as_ref()
1954    }
1955
1956    // ------------------------------------------------------------------
1957    // Phase 1 fact-layer provenance accessors (P1U09).
1958    // ------------------------------------------------------------------
1959
1960    /// Returns the monotonic fact-layer epoch.
1961    #[inline]
1962    #[must_use]
1963    pub fn fact_epoch(&self) -> u64 {
1964        self.fact_epoch
1965    }
1966
1967    /// Looks up node provenance by `NodeId`.
1968    #[inline]
1969    #[must_use]
1970    pub fn node_provenance(
1971        &self,
1972        id: crate::graph::unified::node::id::NodeId,
1973    ) -> Option<&NodeProvenance> {
1974        self.node_provenance.lookup(id)
1975    }
1976
1977    /// Looks up edge provenance by `EdgeId`.
1978    #[inline]
1979    #[must_use]
1980    pub fn edge_provenance(
1981        &self,
1982        id: crate::graph::unified::edge::id::EdgeId,
1983    ) -> Option<&EdgeProvenance> {
1984        self.edge_provenance.lookup(id)
1985    }
1986
1987    /// Returns a borrowed provenance view for a file.
1988    #[inline]
1989    #[must_use]
1990    pub fn file_provenance(
1991        &self,
1992        id: crate::graph::unified::file::id::FileId,
1993    ) -> Option<FileProvenanceView<'_>> {
1994        self.files.file_provenance(id)
1995    }
1996
1997    // ------------------------------------------------------------------
1998    // Phase 2 binding-plane accessors (P2U03).
1999    // ------------------------------------------------------------------
2000
2001    /// Returns a reference to the scope arena at snapshot time.
2002    ///
2003    /// Participates in MVCC: the snapshot holds an `Arc` clone of the arena
2004    /// as it existed when `snapshot()` was called. Subsequent calls to
2005    /// `set_scope_arena` on the source `CodeGraph` do not affect this view.
2006    #[inline]
2007    #[must_use]
2008    pub fn scope_arena(&self) -> &ScopeArena {
2009        &self.scope_arena
2010    }
2011
2012    /// Returns a reference to the alias table at snapshot time.
2013    ///
2014    /// Participates in MVCC: the snapshot holds an `Arc` clone of the table
2015    /// as it existed when `snapshot()` was called. Subsequent calls to
2016    /// `set_alias_table` on the source `CodeGraph` do not affect this view.
2017    #[inline]
2018    #[must_use]
2019    pub fn alias_table(&self) -> &AliasTable {
2020        &self.alias_table
2021    }
2022
2023    /// Returns a reference to the shadow table at snapshot time.
2024    ///
2025    /// Participates in MVCC: the snapshot holds an `Arc` clone of the table
2026    /// as it existed when `snapshot()` was called. Subsequent calls to
2027    /// `set_shadow_table` on the source `CodeGraph` do not affect this view.
2028    #[inline]
2029    #[must_use]
2030    pub fn shadow_table(&self) -> &ShadowTable {
2031        &self.shadow_table
2032    }
2033
2034    /// Returns a reference to the scope provenance store at snapshot time.
2035    ///
2036    /// Participates in MVCC: the snapshot holds an `Arc` clone of the store
2037    /// as it existed when `snapshot()` was called. Subsequent calls to
2038    /// `set_scope_provenance_store` on the source `CodeGraph` do not affect
2039    /// this view.
2040    #[inline]
2041    #[must_use]
2042    pub fn scope_provenance_store(&self) -> &ScopeProvenanceStore {
2043        &self.scope_provenance_store
2044    }
2045
2046    /// Looks up scope provenance by `ScopeId` at snapshot time.
2047    ///
2048    /// Returns `None` if the slot is out of range, vacant, or the stored
2049    /// generation does not match (stale handle).
2050    #[inline]
2051    #[must_use]
2052    pub fn scope_provenance(&self, id: ScopeId) -> Option<&ScopeProvenance> {
2053        self.scope_provenance_store.lookup(id)
2054    }
2055
2056    /// Looks up the live `ScopeId` for a stable scope identity at snapshot time.
2057    ///
2058    /// Returns `None` if no provenance record is registered for that stable id.
2059    #[inline]
2060    #[must_use]
2061    pub fn scope_by_stable_id(&self, stable: ScopeStableId) -> Option<ScopeId> {
2062        self.scope_provenance_store.scope_by_stable_id(stable)
2063    }
2064
2065    /// Returns a reference to the file segment table at snapshot time.
2066    #[inline]
2067    #[must_use]
2068    pub fn file_segments(&self) -> &FileSegmentTable {
2069        &self.file_segments
2070    }
2071
2072    /// Returns the epoch at which this snapshot was taken.
2073    ///
2074    /// This can be compared against the current graph epoch to
2075    /// detect if the graph has changed since the snapshot.
2076    #[inline]
2077    #[must_use]
2078    pub fn epoch(&self) -> u64 {
2079        self.epoch
2080    }
2081
2082    /// Returns `true` if this snapshot's epoch matches the given epoch.
2083    ///
2084    /// Use this to validate cursors before continuing pagination.
2085    #[inline]
2086    #[must_use]
2087    pub fn epoch_matches(&self, other_epoch: u64) -> bool {
2088        self.epoch == other_epoch
2089    }
2090
2091    // ------------------------------------------------------------------
2092    // Phase 2 binding-plane facade accessor (P2U07).
2093    // ------------------------------------------------------------------
2094
2095    /// Returns a [`BindingPlane`] facade borrowing this snapshot's lifetime.
2096    ///
2097    /// The facade is the stable Phase 2 public API for scope/alias/shadow
2098    /// queries and witness-bearing resolution. It provides a single entry
2099    /// point (`resolve`) that returns both a `BindingResult` and an ordered
2100    /// step trace in a `BindingResolution`.
2101    ///
2102    /// # MVCC note
2103    ///
2104    /// `BindingPlane<'_>` borrows from this snapshot, which is already an
2105    /// MVCC-consistent view of the graph at snapshot time. Callers from
2106    /// `CodeGraph` or `ConcurrentCodeGraph` should follow the two-line
2107    /// pattern so the snapshot lifetime is explicit:
2108    ///
2109    /// ```rust,ignore
2110    /// // CodeGraph caller:
2111    /// let snapshot = graph.snapshot();
2112    /// let plane = snapshot.binding_plane();
2113    ///
2114    /// // ConcurrentCodeGraph caller:
2115    /// let read_guard = concurrent.read();
2116    /// let snapshot = read_guard.snapshot();
2117    /// let plane = snapshot.binding_plane();
2118    /// ```
2119    #[inline]
2120    #[must_use]
2121    pub fn binding_plane(&self) -> crate::graph::unified::bind::plane::BindingPlane<'_> {
2122        crate::graph::unified::bind::plane::BindingPlane::new(self)
2123    }
2124
2125    // ============================================================================
2126    // Query Methods
2127    // ============================================================================
2128
2129    /// Finds nodes matching a pattern.
2130    ///
2131    /// Performs a simple substring match on node names and qualified names.
2132    /// Returns all matching node IDs.
2133    ///
2134    /// **Synthetic suppression (`C_SUPPRESS`):** synthetic placeholder
2135    /// nodes — internal scaffolding the language plugins emit for
2136    /// binding-plane and scope analysis (e.g. the Go plugin's
2137    /// `<field:operand.field>` field-access shadows and the
2138    /// `<ident>@<offset>` per-binding-site Variable nodes from the
2139    /// local-scope resolver) — are filtered out by default. Internal
2140    /// callers that need to reach these nodes (binding plane, scope /
2141    /// alias / shadow analysis) use
2142    /// [`Self::find_by_pattern_with_options`] with
2143    /// `include_synthetic = true`.
2144    ///
2145    /// # Performance
2146    ///
2147    /// Optimized to iterate over unique strings in the interner (smaller set)
2148    /// rather than all nodes in the arena.
2149    ///
2150    /// # Arguments
2151    ///
2152    /// * `pattern` - The pattern to match (substring search)
2153    ///
2154    /// # Returns
2155    ///
2156    /// A vector of `NodeIds` for all matching nodes (synthetic
2157    /// placeholders excluded).
2158    #[must_use]
2159    pub fn find_by_pattern(&self, pattern: &str) -> Vec<crate::graph::unified::node::NodeId> {
2160        self.find_by_pattern_with_options(pattern, false)
2161    }
2162
2163    /// Finds nodes matching a pattern with explicit control over synthetic
2164    /// placeholder visibility.
2165    ///
2166    /// `include_synthetic = false` is the default surface used by every
2167    /// user-facing caller (CLI `search`, MCP `semantic_search` /
2168    /// `pattern_search` / `relation_query`, etc.). Synthetic
2169    /// placeholders are suppressed via two parallel checks that must
2170    /// agree:
2171    ///
2172    /// 1. The authoritative `NodeFlags::SYNTHETIC` bit on the
2173    ///    metadata store
2174    ///    ([`crate::graph::unified::storage::metadata::NodeMetadataStore::is_synthetic`]).
2175    /// 2. The structural name-shape fallback
2176    ///    ([`crate::graph::unified::storage::arena::NodeEntry::is_synthetic_placeholder_name`])
2177    ///    for V10 snapshots written before the synthetic bit existed
2178    ///    and for cross-file unification losers that retained their
2179    ///    name but lost their metadata entry.
2180    ///
2181    /// Either check matching is sufficient to suppress the node. The
2182    /// design lives in `docs/development/public-issue-triage/`
2183    /// under the `C_SUPPRESS` unit; see also the rationale in
2184    /// [`crate::graph::unified::storage::metadata::NodeFlags::SYNTHETIC`].
2185    ///
2186    /// `include_synthetic = true` is **internal-only**. The binding
2187    /// plane, scope resolver, and rebuild's coverage gate use this
2188    /// path to reach synthetic nodes for their structural integrity
2189    /// checks. **No CLI / MCP surface should ever pass `true`.**
2190    #[must_use]
2191    pub fn find_by_pattern_with_options(
2192        &self,
2193        pattern: &str,
2194        include_synthetic: bool,
2195    ) -> Vec<crate::graph::unified::node::NodeId> {
2196        let mut matches = Vec::new();
2197
2198        // 1. Scan unique strings in interner for matches
2199        for (str_id, s) in self.strings.iter() {
2200            if s.contains(pattern) {
2201                // 2. If string matches, look up all nodes with this name
2202                // Check qualified name index
2203                matches.extend_from_slice(self.indices.by_qualified_name(str_id));
2204                // Check simple name index
2205                matches.extend_from_slice(self.indices.by_name(str_id));
2206            }
2207        }
2208
2209        // Deduplicate matches (a node might match both qualified and simple name)
2210        matches.sort_unstable();
2211        matches.dedup();
2212
2213        if !include_synthetic {
2214            matches.retain(|&node_id| !self.is_node_synthetic(node_id));
2215        }
2216
2217        matches
2218    }
2219
2220    /// Finds nodes whose interned simple **or** qualified name equals `name`,
2221    /// accepting dot- and Ruby-`#` qualified display form as a fallback for
2222    /// graph-canonical `::` qualified names.
2223    ///
2224    /// This is the canonical surface for **exact-name** lookups —
2225    /// shared by the CLI `--exact <pattern>` shorthand
2226    /// (`sqry-cli/src/commands/search.rs::run_regular_search`) and the
2227    /// structural query planner's `name:` predicate
2228    /// (`sqry-db/src/planner/parse.rs`,
2229    /// `sqry-db/src/planner/execute.rs`). Both surfaces are
2230    /// contract-bound (DAG `B1_ALIGN`) to return the same set against
2231    /// any fixture: the CLI calls this method directly, while the
2232    /// planner uses the same interner + by-name index pair internally
2233    /// when scanning, then applies the same synthetic filter.
2234    ///
2235    /// **Synthetic suppression.** Synthetic placeholder nodes
2236    /// (Go-plugin `<field:operand.field>` shadows and
2237    /// `<ident>@<offset>` per-binding-site Variables; see
2238    /// [`Self::find_by_pattern_with_options`] for the full taxonomy)
2239    /// are excluded via [`Self::is_node_synthetic`]. There is **no**
2240    /// `include_synthetic = true` variant for the exact-match surface
2241    /// because the synthetic name shapes the structural fallback
2242    /// recognises (`<…>`, `…@<offset>`) cannot equal a user-typed
2243    /// name byte-for-byte; the metadata-bit channel is the only
2244    /// realistic leak vector and it is suppressed unconditionally.
2245    ///
2246    /// **Display fallback.** Exact lookup checks the literal input. When the
2247    /// input is qualified, language-aware display candidates win over raw
2248    /// canonical matches. This keeps native Rust `identity::T` from also
2249    /// returning a TypeScript type parameter whose internal canonical form is
2250    /// `identity::T` but whose display form is `identity.T`. If neither
2251    /// display nor literal lookup finds candidates, dot- and Ruby-`#`
2252    /// qualified inputs also check the graph-canonical `::` rewrite.
2253    ///
2254    /// # Performance
2255    ///
2256    /// `O(1)` interner lookup + `O(matches)` filter. If `name` is not
2257    /// interned the resolver still tries a native-display to graph-canonical
2258    /// `::` fallback for user-facing qualified names before returning empty.
2259    ///
2260    /// # Arguments
2261    ///
2262    /// * `name` - The exact name to look up (no glob, no regex).
2263    ///
2264    /// # Returns
2265    ///
2266    /// Sorted, deduplicated `NodeId`s for every non-synthetic node
2267    /// whose `entry.name`, `entry.qualified_name`, or language-aware display
2268    /// name equals `name`, or matches for the graph-canonical `::` rewrite
2269    /// when the user supplied a dot- or Ruby-`#` qualified name that had no
2270    /// literal or display candidates.
2271    #[must_use]
2272    pub fn find_by_exact_name(&self, name: &str) -> Vec<crate::graph::unified::node::NodeId> {
2273        let is_qualified = name.contains('.') || name.contains('#') || name.contains("::");
2274        if !is_qualified {
2275            // Bare name lookup: the interned `by_name` index covers
2276            // every language and is O(1); no display scan is needed.
2277            return self.find_by_exact_interned_name(name);
2278        }
2279        // Qualified name: each plugin stores its native form
2280        // (PHP `Ledger.promotedField`, Rust `Ledger::promotedField`,
2281        // Ruby `Ledger#promotedField`). Scan computed display names so
2282        // a single dotted user query resolves to all language matches,
2283        // then fall back to the interned form (and the graph-canonical
2284        // `::` rewrite) when display yields nothing.
2285        let mut exact_matches = self.find_by_exact_display_name(name);
2286        if exact_matches.is_empty() {
2287            exact_matches = self.find_by_exact_interned_name(name);
2288            if exact_matches.is_empty() && !name.contains("::") {
2289                let canonical = name.replace(['.', '#'], "::");
2290                exact_matches.extend(self.find_by_exact_interned_name(&canonical));
2291                exact_matches.sort_unstable();
2292                exact_matches.dedup();
2293            }
2294        }
2295        exact_matches
2296    }
2297
2298    fn find_by_exact_display_name(&self, name: &str) -> Vec<crate::graph::unified::node::NodeId> {
2299        let mut matches = self
2300            .iter_nodes()
2301            .filter(|(node_id, _)| !self.is_node_synthetic(*node_id))
2302            .filter_map(|(node_id, entry)| {
2303                let qualified = entry
2304                    .qualified_name
2305                    .and_then(|sid| self.strings.resolve(sid))?;
2306                let display = self.files.language_for_file(entry.file).map_or_else(
2307                    || qualified.to_string(),
2308                    |language| {
2309                        display_graph_qualified_name(
2310                            language,
2311                            qualified.as_ref(),
2312                            entry.kind,
2313                            entry.is_static,
2314                        )
2315                    },
2316                );
2317                (display == name).then_some(node_id)
2318            })
2319            .collect::<Vec<_>>();
2320        matches.sort_unstable();
2321        matches.dedup();
2322        matches
2323    }
2324
2325    fn find_by_exact_interned_name(&self, name: &str) -> Vec<crate::graph::unified::node::NodeId> {
2326        let Some(str_id) = self.strings.get(name) else {
2327            return Vec::new();
2328        };
2329
2330        let mut matches: Vec<crate::graph::unified::node::NodeId> = Vec::new();
2331        matches.extend_from_slice(self.indices.by_name(str_id));
2332        matches.extend_from_slice(self.indices.by_qualified_name(str_id));
2333        matches.sort_unstable();
2334        matches.dedup();
2335        matches.retain(|&node_id| !self.is_node_synthetic(node_id));
2336        matches
2337    }
2338
2339    /// Returns `true` if the node should be treated as a synthetic
2340    /// placeholder for user-facing surfaces.
2341    ///
2342    /// Combines the metadata-store flag and the structural name-shape
2343    /// fallback (see [`Self::find_by_pattern_with_options`] for the
2344    /// full rationale). Returns `false` for missing nodes (an unknown
2345    /// `NodeId` is not "synthetic" — it is "not present").
2346    #[must_use]
2347    pub fn is_node_synthetic(&self, node_id: crate::graph::unified::node::NodeId) -> bool {
2348        // Authoritative check: metadata-store bit (NodeFlags::SYNTHETIC).
2349        if self.macro_metadata.is_synthetic(node_id) {
2350            return true;
2351        }
2352        // Structural fallback: name shape recognised as synthetic.
2353        // Required for V10 snapshots written before the bit existed,
2354        // for unification losers that lost their metadata entry, and
2355        // as defence-in-depth against future plugins forgetting to
2356        // flip the bit.
2357        let Some(entry) = self.nodes.get(node_id) else {
2358            return false;
2359        };
2360        if entry.is_unified_loser() {
2361            // Already invisible for other reasons; do not also flag as synthetic.
2362            return false;
2363        }
2364        let Some(name) = self.strings.resolve(entry.name) else {
2365            return false;
2366        };
2367        crate::graph::unified::storage::arena::NodeEntry::is_synthetic_placeholder_name(
2368            name.as_ref(),
2369        )
2370    }
2371
2372    /// Gets all callees of a node (functions called by this node).
2373    ///
2374    /// Queries the forward edge store for all Calls edges from this node.
2375    ///
2376    /// # Arguments
2377    ///
2378    /// * `node` - The node ID to query
2379    ///
2380    /// # Returns
2381    ///
2382    /// A vector of `NodeIds` representing functions called by this node.
2383    #[must_use]
2384    pub fn get_callees(
2385        &self,
2386        node: crate::graph::unified::node::NodeId,
2387    ) -> Vec<crate::graph::unified::node::NodeId> {
2388        use crate::graph::unified::edge::EdgeKind;
2389
2390        self.edges
2391            .edges_from(node)
2392            .into_iter()
2393            .filter(|edge| matches!(edge.kind, EdgeKind::Calls { .. }))
2394            .map(|edge| edge.target)
2395            .collect()
2396    }
2397
2398    /// Gets all callers of a node (functions that call this node).
2399    ///
2400    /// Queries the reverse edge store for all Calls edges to this node.
2401    ///
2402    /// # Arguments
2403    ///
2404    /// * `node` - The node ID to query
2405    ///
2406    /// # Returns
2407    ///
2408    /// A vector of `NodeIds` representing functions that call this node.
2409    #[must_use]
2410    pub fn get_callers(
2411        &self,
2412        node: crate::graph::unified::node::NodeId,
2413    ) -> Vec<crate::graph::unified::node::NodeId> {
2414        use crate::graph::unified::edge::EdgeKind;
2415
2416        self.edges
2417            .edges_to(node)
2418            .into_iter()
2419            .filter(|edge| matches!(edge.kind, EdgeKind::Calls { .. }))
2420            .map(|edge| edge.source)
2421            .collect()
2422    }
2423
2424    /// Iterates over all nodes in the graph.
2425    ///
2426    /// Returns an iterator yielding (`NodeId`, &`NodeEntry`) pairs for all
2427    /// occupied slots in the arena.
2428    ///
2429    /// # Returns
2430    ///
2431    /// An iterator over (`NodeId`, &`NodeEntry`) pairs.
2432    pub fn iter_nodes(
2433        &self,
2434    ) -> impl Iterator<
2435        Item = (
2436            crate::graph::unified::node::NodeId,
2437            &crate::graph::unified::storage::arena::NodeEntry,
2438        ),
2439    > {
2440        self.nodes.iter()
2441    }
2442
2443    /// Iterates over all edges in the graph.
2444    ///
2445    /// Returns an iterator yielding (source, target, `EdgeKind`) tuples for
2446    /// all edges in the forward edge store.
2447    ///
2448    /// # Returns
2449    ///
2450    /// An iterator over edge tuples.
2451    pub fn iter_edges(
2452        &self,
2453    ) -> impl Iterator<
2454        Item = (
2455            crate::graph::unified::node::NodeId,
2456            crate::graph::unified::node::NodeId,
2457            crate::graph::unified::edge::EdgeKind,
2458        ),
2459    > + '_ {
2460        // Iterate over all nodes in the arena and get their outgoing edges
2461        self.nodes.iter().flat_map(move |(node_id, _entry)| {
2462            // Get all edges from this node
2463            self.edges
2464                .edges_from(node_id)
2465                .into_iter()
2466                .map(move |edge| (node_id, edge.target, edge.kind))
2467        })
2468    }
2469
2470    /// Gets a node entry by ID.
2471    ///
2472    /// Returns a reference to the `NodeEntry` if the ID is valid, or None
2473    /// if the ID is invalid or stale.
2474    ///
2475    /// # Arguments
2476    ///
2477    /// * `id` - The node ID to look up
2478    ///
2479    /// # Returns
2480    ///
2481    /// A reference to the `NodeEntry`, or None if not found.
2482    #[must_use]
2483    pub fn get_node(
2484        &self,
2485        id: crate::graph::unified::node::NodeId,
2486    ) -> Option<&crate::graph::unified::storage::arena::NodeEntry> {
2487        self.nodes.get(id)
2488    }
2489}
2490
2491impl fmt::Debug for GraphSnapshot {
2492    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2493        f.debug_struct("GraphSnapshot")
2494            .field("nodes", &self.nodes.len())
2495            .field("epoch", &self.epoch)
2496            .finish_non_exhaustive()
2497    }
2498}
2499
2500/// Read-only accessor trait shared by [`CodeGraph`] and [`GraphSnapshot`].
2501///
2502/// This lets helpers that only *read* graph state (name-matching, relation
2503/// traversal, reference lookups) be written once and called from both the
2504/// live `CodeGraph` path in `sqry-core::query::executor::graph_eval` and
2505/// the snapshot-based path in `sqry-db::queries::*`. No mutation is exposed.
2506pub trait GraphAccess {
2507    /// Returns the node arena (read-only).
2508    fn nodes(&self) -> &NodeArena;
2509    /// Returns the bidirectional edge store (read-only).
2510    fn edges(&self) -> &BidirectionalEdgeStore;
2511    /// Returns the string interner (read-only).
2512    fn strings(&self) -> &StringInterner;
2513    /// Returns the file registry (read-only).
2514    fn files(&self) -> &FileRegistry;
2515    /// Returns the auxiliary indices (read-only).
2516    fn indices(&self) -> &AuxiliaryIndices;
2517}
2518
2519impl GraphAccess for CodeGraph {
2520    #[inline]
2521    fn nodes(&self) -> &NodeArena {
2522        CodeGraph::nodes(self)
2523    }
2524    #[inline]
2525    fn edges(&self) -> &BidirectionalEdgeStore {
2526        CodeGraph::edges(self)
2527    }
2528    #[inline]
2529    fn strings(&self) -> &StringInterner {
2530        CodeGraph::strings(self)
2531    }
2532    #[inline]
2533    fn files(&self) -> &FileRegistry {
2534        CodeGraph::files(self)
2535    }
2536    #[inline]
2537    fn indices(&self) -> &AuxiliaryIndices {
2538        CodeGraph::indices(self)
2539    }
2540}
2541
2542impl GraphAccess for GraphSnapshot {
2543    #[inline]
2544    fn nodes(&self) -> &NodeArena {
2545        GraphSnapshot::nodes(self)
2546    }
2547    #[inline]
2548    fn edges(&self) -> &BidirectionalEdgeStore {
2549        GraphSnapshot::edges(self)
2550    }
2551    #[inline]
2552    fn strings(&self) -> &StringInterner {
2553        GraphSnapshot::strings(self)
2554    }
2555    #[inline]
2556    fn files(&self) -> &FileRegistry {
2557        GraphSnapshot::files(self)
2558    }
2559    #[inline]
2560    fn indices(&self) -> &AuxiliaryIndices {
2561        GraphSnapshot::indices(self)
2562    }
2563}
2564
2565#[cfg(test)]
2566mod tests {
2567    use super::*;
2568    use crate::graph::unified::{
2569        FileScope, NodeId, ResolutionMode, SymbolCandidateOutcome, SymbolQuery,
2570        SymbolResolutionOutcome,
2571    };
2572
2573    fn resolve_symbol_strict(snapshot: &GraphSnapshot, symbol: &str) -> Option<NodeId> {
2574        match snapshot.resolve_symbol(&SymbolQuery {
2575            symbol,
2576            file_scope: FileScope::Any,
2577            mode: ResolutionMode::Strict,
2578        }) {
2579            SymbolResolutionOutcome::Resolved(node_id) => Some(node_id),
2580            SymbolResolutionOutcome::NotFound
2581            | SymbolResolutionOutcome::FileNotIndexed
2582            | SymbolResolutionOutcome::Ambiguous(_) => None,
2583        }
2584    }
2585
2586    fn candidate_nodes(snapshot: &GraphSnapshot, symbol: &str) -> Vec<NodeId> {
2587        match snapshot.find_symbol_candidates(&SymbolQuery {
2588            symbol,
2589            file_scope: FileScope::Any,
2590            mode: ResolutionMode::AllowSuffixCandidates,
2591        }) {
2592            SymbolCandidateOutcome::Candidates(candidates) => candidates,
2593            SymbolCandidateOutcome::NotFound | SymbolCandidateOutcome::FileNotIndexed => Vec::new(),
2594        }
2595    }
2596
2597    #[test]
2598    fn test_code_graph_new() {
2599        let graph = CodeGraph::new();
2600        assert_eq!(graph.epoch(), 0);
2601        assert_eq!(graph.nodes().len(), 0);
2602    }
2603
2604    #[test]
2605    fn test_code_graph_default() {
2606        let graph = CodeGraph::default();
2607        assert_eq!(graph.epoch(), 0);
2608    }
2609
2610    #[test]
2611    fn test_code_graph_snapshot() {
2612        let graph = CodeGraph::new();
2613        let snapshot = graph.snapshot();
2614        assert_eq!(snapshot.epoch(), 0);
2615        assert_eq!(snapshot.nodes().len(), 0);
2616    }
2617
2618    #[test]
2619    fn test_code_graph_bump_epoch() {
2620        let mut graph = CodeGraph::new();
2621        assert_eq!(graph.epoch(), 0);
2622        assert_eq!(graph.bump_epoch(), 1);
2623        assert_eq!(graph.epoch(), 1);
2624        assert_eq!(graph.bump_epoch(), 2);
2625        assert_eq!(graph.epoch(), 2);
2626    }
2627
2628    #[test]
2629    fn test_code_graph_set_epoch() {
2630        let mut graph = CodeGraph::new();
2631        graph.set_epoch(42);
2632        assert_eq!(graph.epoch(), 42);
2633    }
2634
2635    #[test]
2636    fn test_code_graph_from_components() {
2637        let nodes = NodeArena::new();
2638        let edges = BidirectionalEdgeStore::new();
2639        let strings = StringInterner::new();
2640        let files = FileRegistry::new();
2641        let indices = AuxiliaryIndices::new();
2642        let macro_metadata = NodeMetadataStore::new();
2643
2644        let graph =
2645            CodeGraph::from_components(nodes, edges, strings, files, indices, macro_metadata);
2646        assert_eq!(graph.epoch(), 0);
2647    }
2648
2649    #[test]
2650    fn test_code_graph_mut_accessors() {
2651        let mut graph = CodeGraph::new();
2652
2653        // Access mutable references - should not panic
2654        let _nodes = graph.nodes_mut();
2655        let _edges = graph.edges_mut();
2656        let _strings = graph.strings_mut();
2657        let _files = graph.files_mut();
2658        let _indices = graph.indices_mut();
2659    }
2660
2661    #[test]
2662    fn test_code_graph_snapshot_isolation() {
2663        let mut graph = CodeGraph::new();
2664        let snapshot1 = graph.snapshot();
2665
2666        // Mutate the graph
2667        graph.bump_epoch();
2668
2669        let snapshot2 = graph.snapshot();
2670
2671        // Snapshots should have different epochs
2672        assert_eq!(snapshot1.epoch(), 0);
2673        assert_eq!(snapshot2.epoch(), 1);
2674    }
2675
2676    #[test]
2677    fn test_code_graph_debug() {
2678        let graph = CodeGraph::new();
2679        let debug_str = format!("{graph:?}");
2680        assert!(debug_str.contains("CodeGraph"));
2681        assert!(debug_str.contains("epoch"));
2682    }
2683
2684    /// Exact-byte regression for the iter2 fix of CodeGraph.confidence
2685    /// accounting: adding a `ConfidenceMetadata` entry with known inner
2686    /// Vec<String> capacities must increase `heap_bytes()` by exactly the sum
2687    /// of those capacities plus Vec<String> slot overhead.
2688    #[test]
2689    fn test_codegraph_heap_bytes_counts_confidence_inner_strings() {
2690        let mut graph = CodeGraph::new();
2691        // Seed one confidence entry so the HashMap has non-zero capacity;
2692        // reserve slack so the next insert cannot rehash.
2693        graph.set_confidence({
2694            let mut m = HashMap::with_capacity(8);
2695            m.insert("seed".to_string(), ConfidenceMetadata::default());
2696            m
2697        });
2698        let before = graph.heap_bytes();
2699        let before_cap = graph.confidence.capacity();
2700
2701        let lim1 = String::from("no type inference");
2702        let lim2 = String::from("no generic specialization");
2703        let feat1 = String::from("rust-analyzer");
2704        let l1 = lim1.capacity();
2705        let l2 = lim2.capacity();
2706        let f1 = feat1.capacity();
2707
2708        let limitations = vec![lim1, lim2];
2709        let lim_vec_cap = limitations.capacity();
2710
2711        let unavailable_features = vec![feat1];
2712        let feat_vec_cap = unavailable_features.capacity();
2713
2714        let key = String::from("rust");
2715        let key_cap = key.capacity();
2716        graph.confidence.insert(
2717            key,
2718            ConfidenceMetadata {
2719                limitations,
2720                unavailable_features,
2721                ..Default::default()
2722            },
2723        );
2724        assert_eq!(
2725            graph.confidence.capacity(),
2726            before_cap,
2727            "prerequisite: confidence HashMap must not rehash during the test insert",
2728        );
2729
2730        let after = graph.heap_bytes();
2731        let expected_inner = key_cap
2732            + lim_vec_cap * std::mem::size_of::<String>()
2733            + l1
2734            + l2
2735            + feat_vec_cap * std::mem::size_of::<String>()
2736            + f1;
2737        assert_eq!(
2738            after - before,
2739            expected_inner,
2740            "CodeGraph::heap_bytes must count ConfidenceMetadata inner Vec<String> capacity exactly",
2741        );
2742    }
2743
2744    #[test]
2745    fn test_codegraph_heap_bytes_grows_with_content() {
2746        use crate::graph::unified::node::NodeKind;
2747        use crate::graph::unified::storage::arena::NodeEntry;
2748        use std::path::Path;
2749
2750        // An empty graph reports some heap bytes (FileRegistry seeds index 0
2751        // with `vec![None]`, HashMaps have non-zero base capacity once touched,
2752        // etc.) but the value must be finite and well under the 100 MB cap.
2753        let empty = CodeGraph::new();
2754        let empty_bytes = empty.heap_bytes();
2755        assert!(
2756            empty_bytes < 100 * 1024 * 1024,
2757            "empty graph heap_bytes should be <100 MiB, got {empty_bytes}"
2758        );
2759
2760        let mut graph = CodeGraph::new();
2761        for i in 0..32u32 {
2762            let name = format!("sym_{i}");
2763            let qual = format!("module::sym_{i}");
2764            let file = format!("file_{i}.rs");
2765
2766            let name_id = graph.strings_mut().intern(&name).unwrap();
2767            let qual_id = graph.strings_mut().intern(&qual).unwrap();
2768            let file_id = graph.files_mut().register(Path::new(&file)).unwrap();
2769
2770            let entry =
2771                NodeEntry::new(NodeKind::Function, name_id, file_id).with_qualified_name(qual_id);
2772            let node_id = graph.nodes_mut().alloc(entry).unwrap();
2773            graph
2774                .indices_mut()
2775                .add(node_id, NodeKind::Function, name_id, Some(qual_id), file_id);
2776        }
2777
2778        let populated_bytes = graph.heap_bytes();
2779        assert!(
2780            populated_bytes > 0,
2781            "populated graph should report nonzero heap bytes"
2782        );
2783        assert!(
2784            populated_bytes > empty_bytes,
2785            "populated graph ({populated_bytes}) should exceed empty graph ({empty_bytes})"
2786        );
2787        assert!(
2788            populated_bytes < 100 * 1024 * 1024,
2789            "test graph heap_bytes should be <100 MiB, got {populated_bytes}"
2790        );
2791    }
2792
2793    #[test]
2794    fn test_concurrent_code_graph_new() {
2795        let graph = ConcurrentCodeGraph::new();
2796        assert_eq!(graph.epoch(), 0);
2797    }
2798
2799    #[test]
2800    fn test_concurrent_code_graph_default() {
2801        let graph = ConcurrentCodeGraph::default();
2802        assert_eq!(graph.epoch(), 0);
2803    }
2804
2805    #[test]
2806    fn test_concurrent_code_graph_from_graph() {
2807        let mut inner = CodeGraph::new();
2808        inner.set_epoch(10);
2809        let graph = ConcurrentCodeGraph::from_graph(inner);
2810        assert_eq!(graph.epoch(), 10);
2811    }
2812
2813    #[test]
2814    fn test_concurrent_code_graph_read() {
2815        let graph = ConcurrentCodeGraph::new();
2816        let guard = graph.read();
2817        assert_eq!(guard.epoch(), 0);
2818        assert_eq!(guard.nodes().len(), 0);
2819    }
2820
2821    #[test]
2822    fn test_concurrent_code_graph_write_increments_epoch() {
2823        let graph = ConcurrentCodeGraph::new();
2824        assert_eq!(graph.epoch(), 0);
2825
2826        {
2827            let guard = graph.write();
2828            assert_eq!(guard.epoch(), 1);
2829        }
2830
2831        assert_eq!(graph.epoch(), 1);
2832
2833        {
2834            let _guard = graph.write();
2835        }
2836
2837        assert_eq!(graph.epoch(), 2);
2838    }
2839
2840    #[test]
2841    fn test_concurrent_code_graph_snapshot() {
2842        let graph = ConcurrentCodeGraph::new();
2843
2844        {
2845            let _guard = graph.write();
2846        }
2847
2848        let snapshot = graph.snapshot();
2849        assert_eq!(snapshot.epoch(), 1);
2850    }
2851
2852    #[test]
2853    fn test_concurrent_code_graph_try_read() {
2854        let graph = ConcurrentCodeGraph::new();
2855        let guard = graph.try_read();
2856        assert!(guard.is_some());
2857    }
2858
2859    #[test]
2860    fn test_concurrent_code_graph_try_write() {
2861        let graph = ConcurrentCodeGraph::new();
2862        let guard = graph.try_write();
2863        assert!(guard.is_some());
2864        assert_eq!(graph.epoch(), 1);
2865    }
2866
2867    #[test]
2868    fn test_concurrent_code_graph_debug() {
2869        let graph = ConcurrentCodeGraph::new();
2870        let debug_str = format!("{graph:?}");
2871        assert!(debug_str.contains("ConcurrentCodeGraph"));
2872        assert!(debug_str.contains("epoch"));
2873    }
2874
2875    #[test]
2876    fn test_graph_snapshot_accessors() {
2877        let graph = CodeGraph::new();
2878        let snapshot = graph.snapshot();
2879
2880        // All accessors should work
2881        let _nodes = snapshot.nodes();
2882        let _edges = snapshot.edges();
2883        let _strings = snapshot.strings();
2884        let _files = snapshot.files();
2885        let _indices = snapshot.indices();
2886        let _epoch = snapshot.epoch();
2887    }
2888
2889    #[test]
2890    fn test_graph_snapshot_epoch_matches() {
2891        let graph = CodeGraph::new();
2892        let snapshot = graph.snapshot();
2893
2894        assert!(snapshot.epoch_matches(0));
2895        assert!(!snapshot.epoch_matches(1));
2896    }
2897
2898    #[test]
2899    fn test_graph_snapshot_clone() {
2900        let graph = CodeGraph::new();
2901        let snapshot1 = graph.snapshot();
2902        let snapshot2 = snapshot1.clone();
2903
2904        assert_eq!(snapshot1.epoch(), snapshot2.epoch());
2905    }
2906
2907    #[test]
2908    fn test_graph_snapshot_debug() {
2909        let graph = CodeGraph::new();
2910        let snapshot = graph.snapshot();
2911        let debug_str = format!("{snapshot:?}");
2912        assert!(debug_str.contains("GraphSnapshot"));
2913        assert!(debug_str.contains("epoch"));
2914    }
2915
2916    #[test]
2917    fn test_multiple_readers() {
2918        let graph = ConcurrentCodeGraph::new();
2919
2920        // Multiple readers should be able to acquire locks simultaneously
2921        let guard1 = graph.read();
2922        let guard2 = graph.read();
2923        let guard3 = graph.read();
2924
2925        assert_eq!(guard1.epoch(), 0);
2926        assert_eq!(guard2.epoch(), 0);
2927        assert_eq!(guard3.epoch(), 0);
2928    }
2929
2930    #[test]
2931    fn test_code_graph_clone() {
2932        let mut graph = CodeGraph::new();
2933        graph.bump_epoch();
2934
2935        let cloned = graph.clone();
2936        assert_eq!(cloned.epoch(), 1);
2937    }
2938
2939    #[test]
2940    fn test_epoch_wrapping() {
2941        let mut graph = CodeGraph::new();
2942        graph.set_epoch(u64::MAX);
2943        let new_epoch = graph.bump_epoch();
2944        assert_eq!(new_epoch, 0); // Should wrap around
2945    }
2946
2947    // ============================================================================
2948    // Query method tests
2949    // ============================================================================
2950
2951    #[test]
2952    fn test_snapshot_resolve_symbol() {
2953        use crate::graph::unified::node::NodeKind;
2954        use crate::graph::unified::storage::arena::NodeEntry;
2955        use std::path::Path;
2956
2957        let mut graph = CodeGraph::new();
2958
2959        // Add some nodes with qualified names
2960        let name_id = graph.strings_mut().intern("test_func").unwrap();
2961        let qual_name_id = graph.strings_mut().intern("module::test_func").unwrap();
2962        let file_id = graph.files_mut().register(Path::new("test.rs")).unwrap();
2963
2964        let entry =
2965            NodeEntry::new(NodeKind::Function, name_id, file_id).with_qualified_name(qual_name_id);
2966
2967        let node_id = graph.nodes_mut().alloc(entry).unwrap();
2968        graph.indices_mut().add(
2969            node_id,
2970            NodeKind::Function,
2971            name_id,
2972            Some(qual_name_id),
2973            file_id,
2974        );
2975
2976        let snapshot = graph.snapshot();
2977
2978        // Find by qualified name
2979        let found = resolve_symbol_strict(&snapshot, "module::test_func");
2980        assert_eq!(found, Some(node_id));
2981
2982        // Find by exact simple name
2983        let found2 = resolve_symbol_strict(&snapshot, "test_func");
2984        assert_eq!(found2, Some(node_id));
2985
2986        // Not found
2987        assert!(resolve_symbol_strict(&snapshot, "nonexistent").is_none());
2988    }
2989
2990    #[test]
2991    fn test_snapshot_find_by_pattern() {
2992        use crate::graph::unified::node::NodeKind;
2993        use crate::graph::unified::storage::arena::NodeEntry;
2994        use std::path::Path;
2995
2996        let mut graph = CodeGraph::new();
2997
2998        // Add nodes with different names
2999        let name1 = graph.strings_mut().intern("foo_bar").unwrap();
3000        let name2 = graph.strings_mut().intern("baz_bar").unwrap();
3001        let name3 = graph.strings_mut().intern("qux_test").unwrap();
3002        let file_id = graph.files_mut().register(Path::new("test.rs")).unwrap();
3003
3004        let node1 = graph
3005            .nodes_mut()
3006            .alloc(NodeEntry::new(NodeKind::Function, name1, file_id))
3007            .unwrap();
3008        let node2 = graph
3009            .nodes_mut()
3010            .alloc(NodeEntry::new(NodeKind::Function, name2, file_id))
3011            .unwrap();
3012        let node3 = graph
3013            .nodes_mut()
3014            .alloc(NodeEntry::new(NodeKind::Function, name3, file_id))
3015            .unwrap();
3016
3017        graph
3018            .indices_mut()
3019            .add(node1, NodeKind::Function, name1, None, file_id);
3020        graph
3021            .indices_mut()
3022            .add(node2, NodeKind::Function, name2, None, file_id);
3023        graph
3024            .indices_mut()
3025            .add(node3, NodeKind::Function, name3, None, file_id);
3026
3027        let snapshot = graph.snapshot();
3028
3029        // Find by pattern
3030        let matches = snapshot.find_by_pattern("bar");
3031        assert_eq!(matches.len(), 2);
3032        assert!(matches.contains(&node1));
3033        assert!(matches.contains(&node2));
3034
3035        // Find single match
3036        let matches = snapshot.find_by_pattern("qux");
3037        assert_eq!(matches.len(), 1);
3038        assert_eq!(matches[0], node3);
3039
3040        // No matches
3041        let matches = snapshot.find_by_pattern("nonexistent");
3042        assert!(matches.is_empty());
3043    }
3044
3045    #[test]
3046    fn synthetic_nodes_are_filtered_from_find_by_pattern_default() {
3047        // C_SUPPRESS: synthetic placeholder nodes (Go plugin
3048        // `<field:operand.field>` shadows + `<ident>@<offset>`
3049        // per-binding-site Variables) must NOT surface from
3050        // find_by_pattern, but must still be reachable via
3051        // find_by_pattern_with_options(_, true).
3052        use crate::graph::unified::node::NodeKind;
3053        use crate::graph::unified::storage::arena::NodeEntry;
3054        use std::path::Path;
3055
3056        let mut graph = CodeGraph::new();
3057        let real_property = graph
3058            .strings_mut()
3059            .intern("main.SelectorSource.NeedTags")
3060            .unwrap();
3061        let real_local_var = graph.strings_mut().intern("NeedTags").unwrap();
3062        let synthetic_field = graph
3063            .strings_mut()
3064            .intern("<field:selector.NeedTags>")
3065            .unwrap();
3066        let synthetic_offset_a = graph.strings_mut().intern("NeedTags@469").unwrap();
3067        let synthetic_offset_b = graph.strings_mut().intern("NeedTags@508").unwrap();
3068        let file_id = graph.files_mut().register(Path::new("main.go")).unwrap();
3069
3070        let prop_id = graph
3071            .nodes_mut()
3072            .alloc(NodeEntry::new(NodeKind::Property, real_property, file_id))
3073            .unwrap();
3074        let local_var_id = graph
3075            .nodes_mut()
3076            .alloc(NodeEntry::new(NodeKind::Variable, real_local_var, file_id))
3077            .unwrap();
3078        let syn_field_id = graph
3079            .nodes_mut()
3080            .alloc(NodeEntry::new(NodeKind::Variable, synthetic_field, file_id))
3081            .unwrap();
3082        let syn_a_id = graph
3083            .nodes_mut()
3084            .alloc(NodeEntry::new(
3085                NodeKind::Variable,
3086                synthetic_offset_a,
3087                file_id,
3088            ))
3089            .unwrap();
3090        let syn_b_id = graph
3091            .nodes_mut()
3092            .alloc(NodeEntry::new(
3093                NodeKind::Variable,
3094                synthetic_offset_b,
3095                file_id,
3096            ))
3097            .unwrap();
3098
3099        graph
3100            .indices_mut()
3101            .add(prop_id, NodeKind::Property, real_property, None, file_id);
3102        graph.indices_mut().add(
3103            local_var_id,
3104            NodeKind::Variable,
3105            real_local_var,
3106            None,
3107            file_id,
3108        );
3109        graph.indices_mut().add(
3110            syn_field_id,
3111            NodeKind::Variable,
3112            synthetic_field,
3113            None,
3114            file_id,
3115        );
3116        graph.indices_mut().add(
3117            syn_a_id,
3118            NodeKind::Variable,
3119            synthetic_offset_a,
3120            None,
3121            file_id,
3122        );
3123        graph.indices_mut().add(
3124            syn_b_id,
3125            NodeKind::Variable,
3126            synthetic_offset_b,
3127            None,
3128            file_id,
3129        );
3130
3131        // Flag two of them via the metadata-store bit (the canonical
3132        // Go-plugin emission path) and leave one (`<field:...>`) only
3133        // covered by the structural name-shape fallback to verify both
3134        // recognition channels suppress the leak.
3135        graph.macro_metadata_mut().mark_synthetic(syn_a_id);
3136        graph.macro_metadata_mut().mark_synthetic(syn_b_id);
3137
3138        let snapshot = graph.snapshot();
3139
3140        // Default surface (CLI `search --exact`, MCP, LSP): no synthetics.
3141        let matches = snapshot.find_by_pattern("NeedTags");
3142        assert!(matches.contains(&prop_id), "Property must be surfaced");
3143        assert!(
3144            matches.contains(&local_var_id),
3145            "real local var must be surfaced"
3146        );
3147        assert!(
3148            !matches.contains(&syn_field_id),
3149            "<field:...> synthetic must be suppressed (name-shape fallback)"
3150        );
3151        assert!(
3152            !matches.contains(&syn_a_id),
3153            "NeedTags@469 must be suppressed (metadata bit)"
3154        );
3155        assert!(
3156            !matches.contains(&syn_b_id),
3157            "NeedTags@508 must be suppressed (metadata bit)"
3158        );
3159        assert_eq!(matches.len(), 2, "exactly Property + local var, no leakage");
3160
3161        // Internal include-synthetic surface (binding plane, scope analysis):
3162        // every node remains reachable.
3163        let all_matches = snapshot.find_by_pattern_with_options("NeedTags", true);
3164        assert_eq!(
3165            all_matches.len(),
3166            5,
3167            "include_synthetic surfaces everything"
3168        );
3169        assert!(all_matches.contains(&prop_id));
3170        assert!(all_matches.contains(&local_var_id));
3171        assert!(all_matches.contains(&syn_field_id));
3172        assert!(all_matches.contains(&syn_a_id));
3173        assert!(all_matches.contains(&syn_b_id));
3174
3175        // is_node_synthetic exposed for surface-level filters
3176        // (e.g., MCP semantic_search/relation_query post-filters).
3177        assert!(snapshot.is_node_synthetic(syn_field_id));
3178        assert!(snapshot.is_node_synthetic(syn_a_id));
3179        assert!(snapshot.is_node_synthetic(syn_b_id));
3180        assert!(!snapshot.is_node_synthetic(prop_id));
3181        assert!(!snapshot.is_node_synthetic(local_var_id));
3182    }
3183
3184    #[test]
3185    #[allow(clippy::too_many_lines)]
3186    fn find_by_exact_name_aligns_with_planner_name_predicate() {
3187        // B1_ALIGN: `find_by_exact_name("NeedTags")` is the canonical
3188        // surface for the CLI `--exact NeedTags` shorthand and the
3189        // planner's `name:NeedTags` predicate. Both paths must return
3190        // the same set against this fixture.
3191        use crate::graph::unified::node::NodeKind;
3192        use crate::graph::unified::storage::arena::NodeEntry;
3193        use std::path::Path;
3194
3195        let mut graph = CodeGraph::new();
3196        // Property nodes carry the package-qualified name as
3197        // `entry.name` (Go plugin convention; see `helper.rs`'s
3198        // `semantic_name_for_node_input`).
3199        let property_qname = graph
3200            .strings_mut()
3201            .intern("main.SelectorSource.NeedTags")
3202            .unwrap();
3203        let local_var_name = graph.strings_mut().intern("NeedTags").unwrap();
3204        let synthetic_field_name = graph
3205            .strings_mut()
3206            .intern("<field:selector.NeedTags>")
3207            .unwrap();
3208        let synthetic_offset_name = graph.strings_mut().intern("NeedTags@469").unwrap();
3209        let unrelated_name = graph.strings_mut().intern("NeedTagsHelper").unwrap();
3210        let display_fallback_name = graph.strings_mut().intern("Other").unwrap();
3211        let display_fallback_qname = graph
3212            .strings_mut()
3213            .intern("main::SelectorSource::Other")
3214            .unwrap();
3215        let file_id = graph.files_mut().register(Path::new("main.go")).unwrap();
3216
3217        let prop_id = graph
3218            .nodes_mut()
3219            .alloc(NodeEntry::new(NodeKind::Property, property_qname, file_id))
3220            .unwrap();
3221        let local_var_id = graph
3222            .nodes_mut()
3223            .alloc(NodeEntry::new(NodeKind::Variable, local_var_name, file_id))
3224            .unwrap();
3225        let syn_field_id = graph
3226            .nodes_mut()
3227            .alloc(NodeEntry::new(
3228                NodeKind::Variable,
3229                synthetic_field_name,
3230                file_id,
3231            ))
3232            .unwrap();
3233        let syn_offset_id = graph
3234            .nodes_mut()
3235            .alloc(NodeEntry::new(
3236                NodeKind::Variable,
3237                synthetic_offset_name,
3238                file_id,
3239            ))
3240            .unwrap();
3241        let unrelated_id = graph
3242            .nodes_mut()
3243            .alloc(NodeEntry::new(NodeKind::Function, unrelated_name, file_id))
3244            .unwrap();
3245        let display_fallback_id = graph
3246            .nodes_mut()
3247            .alloc(
3248                NodeEntry::new(NodeKind::Property, display_fallback_name, file_id)
3249                    .with_qualified_name(display_fallback_qname),
3250            )
3251            .unwrap();
3252
3253        graph
3254            .indices_mut()
3255            .add(prop_id, NodeKind::Property, property_qname, None, file_id);
3256        graph.indices_mut().add(
3257            local_var_id,
3258            NodeKind::Variable,
3259            local_var_name,
3260            None,
3261            file_id,
3262        );
3263        graph.indices_mut().add(
3264            syn_field_id,
3265            NodeKind::Variable,
3266            synthetic_field_name,
3267            None,
3268            file_id,
3269        );
3270        graph.indices_mut().add(
3271            syn_offset_id,
3272            NodeKind::Variable,
3273            synthetic_offset_name,
3274            None,
3275            file_id,
3276        );
3277        graph.indices_mut().add(
3278            unrelated_id,
3279            NodeKind::Function,
3280            unrelated_name,
3281            None,
3282            file_id,
3283        );
3284        graph.indices_mut().add(
3285            display_fallback_id,
3286            NodeKind::Property,
3287            display_fallback_name,
3288            Some(display_fallback_qname),
3289            file_id,
3290        );
3291
3292        // Mark the offset-suffixed synthetic via the metadata bit so we
3293        // exercise both recognition channels in this fixture.
3294        graph.macro_metadata_mut().mark_synthetic(syn_offset_id);
3295
3296        let snapshot = graph.snapshot();
3297
3298        // Exact-name lookup on "NeedTags" — should pick up only the
3299        // local variable (its `entry.name` is exactly "NeedTags"); it
3300        // must NOT pick up the Property (qualified name contains but
3301        // does not equal "NeedTags") and must NOT pick up either
3302        // synthetic placeholder.
3303        let exact = snapshot.find_by_exact_name("NeedTags");
3304        assert_eq!(
3305            exact,
3306            vec![local_var_id],
3307            "exact match must be byte-for-byte against entry.name / qualified_name and exclude synthetics"
3308        );
3309
3310        // The Property's full qualified name is exact-addressable.
3311        let qualified = snapshot.find_by_exact_name("main.SelectorSource.NeedTags");
3312        assert_eq!(qualified, vec![prop_id]);
3313
3314        // Dot-qualified display form falls back to graph-canonical `::` only
3315        // when the exact dot string was absent.
3316        let display_fallback = snapshot.find_by_exact_name("main.SelectorSource.Other");
3317        assert_eq!(display_fallback, vec![display_fallback_id]);
3318
3319        // Substring-only matches must not surface from exact lookup.
3320        assert!(
3321            snapshot
3322                .find_by_exact_name("NeedTagsHelper")
3323                .contains(&unrelated_id)
3324        );
3325        assert!(
3326            !snapshot
3327                .find_by_exact_name("NeedTags")
3328                .contains(&unrelated_id),
3329            "exact 'NeedTags' must not match 'NeedTagsHelper'"
3330        );
3331
3332        // Unknown name short-circuits to an empty vec without
3333        // scanning any nodes.
3334        assert!(
3335            snapshot
3336                .find_by_exact_name("ThisStringIsNotInterned")
3337                .is_empty()
3338        );
3339    }
3340
3341    #[test]
3342    fn test_snapshot_get_callees() {
3343        use crate::graph::unified::edge::EdgeKind;
3344        use crate::graph::unified::node::NodeKind;
3345        use crate::graph::unified::storage::arena::NodeEntry;
3346        use std::path::Path;
3347
3348        let mut graph = CodeGraph::new();
3349
3350        // Create caller and callee nodes
3351        let caller_name = graph.strings_mut().intern("caller").unwrap();
3352        let callee1_name = graph.strings_mut().intern("callee1").unwrap();
3353        let callee2_name = graph.strings_mut().intern("callee2").unwrap();
3354        let file_id = graph.files_mut().register(Path::new("test.rs")).unwrap();
3355
3356        let caller_id = graph
3357            .nodes_mut()
3358            .alloc(NodeEntry::new(NodeKind::Function, caller_name, file_id))
3359            .unwrap();
3360        let callee1_id = graph
3361            .nodes_mut()
3362            .alloc(NodeEntry::new(NodeKind::Function, callee1_name, file_id))
3363            .unwrap();
3364        let callee2_id = graph
3365            .nodes_mut()
3366            .alloc(NodeEntry::new(NodeKind::Function, callee2_name, file_id))
3367            .unwrap();
3368
3369        // Add call edges
3370        graph.edges_mut().add_edge(
3371            caller_id,
3372            callee1_id,
3373            EdgeKind::Calls {
3374                argument_count: 0,
3375                is_async: false,
3376                resolved_via: ResolvedVia::Direct,
3377            },
3378            file_id,
3379        );
3380        graph.edges_mut().add_edge(
3381            caller_id,
3382            callee2_id,
3383            EdgeKind::Calls {
3384                argument_count: 0,
3385                is_async: false,
3386                resolved_via: ResolvedVia::Direct,
3387            },
3388            file_id,
3389        );
3390
3391        let snapshot = graph.snapshot();
3392
3393        // Query callees
3394        let callees = snapshot.get_callees(caller_id);
3395        assert_eq!(callees.len(), 2);
3396        assert!(callees.contains(&callee1_id));
3397        assert!(callees.contains(&callee2_id));
3398
3399        // Node with no callees
3400        let callees = snapshot.get_callees(callee1_id);
3401        assert!(callees.is_empty());
3402    }
3403
3404    #[test]
3405    fn test_snapshot_get_callers() {
3406        use crate::graph::unified::edge::EdgeKind;
3407        use crate::graph::unified::node::NodeKind;
3408        use crate::graph::unified::storage::arena::NodeEntry;
3409        use std::path::Path;
3410
3411        let mut graph = CodeGraph::new();
3412
3413        // Create caller and callee nodes
3414        let caller1_name = graph.strings_mut().intern("caller1").unwrap();
3415        let caller2_name = graph.strings_mut().intern("caller2").unwrap();
3416        let callee_name = graph.strings_mut().intern("callee").unwrap();
3417        let file_id = graph.files_mut().register(Path::new("test.rs")).unwrap();
3418
3419        let caller1_id = graph
3420            .nodes_mut()
3421            .alloc(NodeEntry::new(NodeKind::Function, caller1_name, file_id))
3422            .unwrap();
3423        let caller2_id = graph
3424            .nodes_mut()
3425            .alloc(NodeEntry::new(NodeKind::Function, caller2_name, file_id))
3426            .unwrap();
3427        let callee_id = graph
3428            .nodes_mut()
3429            .alloc(NodeEntry::new(NodeKind::Function, callee_name, file_id))
3430            .unwrap();
3431
3432        // Add call edges
3433        graph.edges_mut().add_edge(
3434            caller1_id,
3435            callee_id,
3436            EdgeKind::Calls {
3437                argument_count: 0,
3438                is_async: false,
3439                resolved_via: ResolvedVia::Direct,
3440            },
3441            file_id,
3442        );
3443        graph.edges_mut().add_edge(
3444            caller2_id,
3445            callee_id,
3446            EdgeKind::Calls {
3447                argument_count: 0,
3448                is_async: false,
3449                resolved_via: ResolvedVia::Direct,
3450            },
3451            file_id,
3452        );
3453
3454        let snapshot = graph.snapshot();
3455
3456        // Query callers
3457        let callers = snapshot.get_callers(callee_id);
3458        assert_eq!(callers.len(), 2);
3459        assert!(callers.contains(&caller1_id));
3460        assert!(callers.contains(&caller2_id));
3461
3462        // Node with no callers
3463        let callers = snapshot.get_callers(caller1_id);
3464        assert!(callers.is_empty());
3465    }
3466
3467    #[test]
3468    fn test_snapshot_find_symbol_candidates() {
3469        use crate::graph::unified::node::NodeKind;
3470        use crate::graph::unified::storage::arena::NodeEntry;
3471        use std::path::Path;
3472
3473        let mut graph = CodeGraph::new();
3474
3475        // Add nodes with same symbol name but different qualified names
3476        let symbol_name = graph.strings_mut().intern("test").unwrap();
3477        let file_id = graph.files_mut().register(Path::new("test.rs")).unwrap();
3478
3479        let node1 = graph
3480            .nodes_mut()
3481            .alloc(NodeEntry::new(NodeKind::Function, symbol_name, file_id))
3482            .unwrap();
3483        let node2 = graph
3484            .nodes_mut()
3485            .alloc(NodeEntry::new(NodeKind::Method, symbol_name, file_id))
3486            .unwrap();
3487
3488        // Add a different symbol
3489        let other_name = graph.strings_mut().intern("other").unwrap();
3490        let node3 = graph
3491            .nodes_mut()
3492            .alloc(NodeEntry::new(NodeKind::Function, other_name, file_id))
3493            .unwrap();
3494
3495        graph
3496            .indices_mut()
3497            .add(node1, NodeKind::Function, symbol_name, None, file_id);
3498        graph
3499            .indices_mut()
3500            .add(node2, NodeKind::Method, symbol_name, None, file_id);
3501        graph
3502            .indices_mut()
3503            .add(node3, NodeKind::Function, other_name, None, file_id);
3504
3505        let snapshot = graph.snapshot();
3506
3507        // Find by symbol
3508        let matches = candidate_nodes(&snapshot, "test");
3509        assert_eq!(matches.len(), 2);
3510        assert!(matches.contains(&node1));
3511        assert!(matches.contains(&node2));
3512
3513        // Find other symbol
3514        let matches = candidate_nodes(&snapshot, "other");
3515        assert_eq!(matches.len(), 1);
3516        assert_eq!(matches[0], node3);
3517
3518        // No matches
3519        let matches = candidate_nodes(&snapshot, "nonexistent");
3520        assert!(matches.is_empty());
3521    }
3522
3523    #[test]
3524    fn test_snapshot_iter_nodes() {
3525        use crate::graph::unified::node::NodeKind;
3526        use crate::graph::unified::storage::arena::NodeEntry;
3527        use std::path::Path;
3528
3529        let mut graph = CodeGraph::new();
3530
3531        // Add some nodes
3532        let name1 = graph.strings_mut().intern("func1").unwrap();
3533        let name2 = graph.strings_mut().intern("func2").unwrap();
3534        let file_id = graph.files_mut().register(Path::new("test.rs")).unwrap();
3535
3536        let node1 = graph
3537            .nodes_mut()
3538            .alloc(NodeEntry::new(NodeKind::Function, name1, file_id))
3539            .unwrap();
3540        let node2 = graph
3541            .nodes_mut()
3542            .alloc(NodeEntry::new(NodeKind::Function, name2, file_id))
3543            .unwrap();
3544
3545        let snapshot = graph.snapshot();
3546
3547        // Iterate nodes
3548        let snapshot_nodes: Vec<_> = snapshot.iter_nodes().collect();
3549        assert_eq!(snapshot_nodes.len(), 2);
3550
3551        let node_ids: Vec<_> = snapshot_nodes.iter().map(|(id, _)| *id).collect();
3552        assert!(node_ids.contains(&node1));
3553        assert!(node_ids.contains(&node2));
3554    }
3555
3556    #[test]
3557    fn test_snapshot_iter_edges() {
3558        use crate::graph::unified::edge::EdgeKind;
3559        use crate::graph::unified::node::NodeKind;
3560        use crate::graph::unified::storage::arena::NodeEntry;
3561        use std::path::Path;
3562
3563        let mut graph = CodeGraph::new();
3564
3565        // Create nodes
3566        let name1 = graph.strings_mut().intern("func1").unwrap();
3567        let name2 = graph.strings_mut().intern("func2").unwrap();
3568        let file_id = graph.files_mut().register(Path::new("test.rs")).unwrap();
3569
3570        let node1 = graph
3571            .nodes_mut()
3572            .alloc(NodeEntry::new(NodeKind::Function, name1, file_id))
3573            .unwrap();
3574        let node2 = graph
3575            .nodes_mut()
3576            .alloc(NodeEntry::new(NodeKind::Function, name2, file_id))
3577            .unwrap();
3578
3579        // Add edges
3580        graph.edges_mut().add_edge(
3581            node1,
3582            node2,
3583            EdgeKind::Calls {
3584                argument_count: 0,
3585                is_async: false,
3586                resolved_via: ResolvedVia::Direct,
3587            },
3588            file_id,
3589        );
3590
3591        let snapshot = graph.snapshot();
3592
3593        // Iterate edges
3594        let edges: Vec<_> = snapshot.iter_edges().collect();
3595        assert_eq!(edges.len(), 1);
3596
3597        let (src, tgt, kind) = &edges[0];
3598        assert_eq!(*src, node1);
3599        assert_eq!(*tgt, node2);
3600        assert!(matches!(
3601            kind,
3602            EdgeKind::Calls {
3603                argument_count: 0,
3604                is_async: false,
3605                resolved_via: ResolvedVia::Direct,
3606            }
3607        ));
3608    }
3609
3610    #[test]
3611    fn test_snapshot_get_node() {
3612        use crate::graph::unified::node::NodeId;
3613        use crate::graph::unified::node::NodeKind;
3614        use crate::graph::unified::storage::arena::NodeEntry;
3615        use std::path::Path;
3616
3617        let mut graph = CodeGraph::new();
3618
3619        // Add a node
3620        let name = graph.strings_mut().intern("test_func").unwrap();
3621        let file_id = graph.files_mut().register(Path::new("test.rs")).unwrap();
3622
3623        let node_id = graph
3624            .nodes_mut()
3625            .alloc(NodeEntry::new(NodeKind::Function, name, file_id))
3626            .unwrap();
3627
3628        let snapshot = graph.snapshot();
3629
3630        // Get node
3631        let entry = snapshot.get_node(node_id);
3632        assert!(entry.is_some());
3633        assert_eq!(entry.unwrap().kind, NodeKind::Function);
3634
3635        // Invalid node
3636        let invalid_id = NodeId::INVALID;
3637        assert!(snapshot.get_node(invalid_id).is_none());
3638    }
3639
3640    #[test]
3641    fn test_snapshot_query_empty_graph() {
3642        use crate::graph::unified::node::NodeId;
3643
3644        let graph = CodeGraph::new();
3645        let snapshot = graph.snapshot();
3646
3647        // All queries should return empty on empty graph
3648        assert!(resolve_symbol_strict(&snapshot, "test").is_none());
3649        assert!(snapshot.find_by_pattern("test").is_empty());
3650        assert!(candidate_nodes(&snapshot, "test").is_empty());
3651
3652        let dummy_id = NodeId::new(0, 1);
3653        assert!(snapshot.get_callees(dummy_id).is_empty());
3654        assert!(snapshot.get_callers(dummy_id).is_empty());
3655
3656        assert_eq!(snapshot.iter_nodes().count(), 0);
3657        assert_eq!(snapshot.iter_edges().count(), 0);
3658    }
3659
3660    #[test]
3661    fn test_snapshot_edge_filtering_by_kind() {
3662        use crate::graph::unified::edge::EdgeKind;
3663        use crate::graph::unified::node::NodeKind;
3664        use crate::graph::unified::storage::arena::NodeEntry;
3665        use std::path::Path;
3666
3667        let mut graph = CodeGraph::new();
3668
3669        // Create nodes
3670        let name1 = graph.strings_mut().intern("func1").unwrap();
3671        let name2 = graph.strings_mut().intern("func2").unwrap();
3672        let file_id = graph.files_mut().register(Path::new("test.rs")).unwrap();
3673
3674        let node1 = graph
3675            .nodes_mut()
3676            .alloc(NodeEntry::new(NodeKind::Function, name1, file_id))
3677            .unwrap();
3678        let node2 = graph
3679            .nodes_mut()
3680            .alloc(NodeEntry::new(NodeKind::Function, name2, file_id))
3681            .unwrap();
3682
3683        // Add different kinds of edges
3684        graph.edges_mut().add_edge(
3685            node1,
3686            node2,
3687            EdgeKind::Calls {
3688                argument_count: 0,
3689                is_async: false,
3690                resolved_via: ResolvedVia::Direct,
3691            },
3692            file_id,
3693        );
3694        graph
3695            .edges_mut()
3696            .add_edge(node1, node2, EdgeKind::References, file_id);
3697
3698        let snapshot = graph.snapshot();
3699
3700        // get_callees should only return Calls edges
3701        let callees = snapshot.get_callees(node1);
3702        assert_eq!(callees.len(), 1);
3703        assert_eq!(callees[0], node2);
3704
3705        // iter_edges returns all edges regardless of kind
3706        let edges: Vec<_> = snapshot.iter_edges().collect();
3707        assert_eq!(edges.len(), 2);
3708    }
3709
3710    // -------- reverse_import_index tests --------
3711
3712    /// Helper: build an empty graph with the given file paths registered, a
3713    /// single placeholder node allocated per file, and indices rebuilt so
3714    /// `by_file` returns the per-file node sets. Returns the file IDs in
3715    /// the order passed in and the per-file node ID.
3716    #[cfg(test)]
3717    fn build_import_test_graph(files: &[&str]) -> (CodeGraph, Vec<FileId>, Vec<NodeId>) {
3718        use crate::graph::unified::node::NodeKind;
3719        use crate::graph::unified::storage::arena::NodeEntry;
3720        use std::path::Path;
3721
3722        let mut graph = CodeGraph::new();
3723        let placeholder_name = graph.strings_mut().intern("sym").unwrap();
3724        let mut file_ids = Vec::with_capacity(files.len());
3725        let mut node_ids = Vec::with_capacity(files.len());
3726        for path in files {
3727            let file_id = graph.files_mut().register(Path::new(path)).unwrap();
3728            let node_id = graph
3729                .nodes_mut()
3730                .alloc(NodeEntry::new(
3731                    NodeKind::Function,
3732                    placeholder_name,
3733                    file_id,
3734                ))
3735                .unwrap();
3736            file_ids.push(file_id);
3737            node_ids.push(node_id);
3738        }
3739        graph.rebuild_indices();
3740        (graph, file_ids, node_ids)
3741    }
3742
3743    /// Helper: add an `Imports` edge from `source_node` (in importer file) to
3744    /// `target_node` (in exporter file). The edge is recorded against the
3745    /// importer file so `clear_file` cleanup would behave identically to
3746    /// production Pass 4 writes.
3747    #[cfg(test)]
3748    fn add_import_edge(
3749        graph: &mut CodeGraph,
3750        source_node: NodeId,
3751        target_node: NodeId,
3752        importer_file: FileId,
3753    ) {
3754        graph.edges_mut().add_edge(
3755            source_node,
3756            target_node,
3757            EdgeKind::Imports {
3758                alias: None,
3759                is_wildcard: false,
3760            },
3761            importer_file,
3762        );
3763    }
3764
3765    #[test]
3766    fn reverse_import_index_empty_graph_returns_empty() {
3767        let (graph, files, _) = build_import_test_graph(&["only.rs"]);
3768        assert!(graph.reverse_import_index(files[0]).is_empty());
3769    }
3770
3771    #[test]
3772    fn reverse_import_index_single_importer() {
3773        // File A imports a symbol exported by file B. Reverse index of B
3774        // should return exactly [A]; reverse index of A should be empty.
3775        let (mut graph, files, nodes) = build_import_test_graph(&["a.rs", "b.rs"]);
3776        let (a, b) = (files[0], files[1]);
3777        add_import_edge(&mut graph, nodes[0], nodes[1], a);
3778
3779        let importers_of_b = graph.reverse_import_index(b);
3780        assert_eq!(importers_of_b, vec![a]);
3781
3782        let importers_of_a = graph.reverse_import_index(a);
3783        assert!(
3784            importers_of_a.is_empty(),
3785            "A has no inbound Imports edges; reverse index must be empty"
3786        );
3787    }
3788
3789    #[test]
3790    fn reverse_import_index_multiple_importers_deduped_and_sorted() {
3791        // A, B, C all import from D. Reverse index of D must contain each
3792        // importer exactly once, sorted ascending by FileId.
3793        let (mut graph, files, nodes) = build_import_test_graph(&["a.rs", "b.rs", "c.rs", "d.rs"]);
3794        let (a, b, c, d) = (files[0], files[1], files[2], files[3]);
3795        add_import_edge(&mut graph, nodes[0], nodes[3], a);
3796        add_import_edge(&mut graph, nodes[1], nodes[3], b);
3797        add_import_edge(&mut graph, nodes[2], nodes[3], c);
3798        // Add a duplicate edge from A to D to confirm dedup behavior.
3799        add_import_edge(&mut graph, nodes[0], nodes[3], a);
3800
3801        let importers_of_d = graph.reverse_import_index(d);
3802        assert_eq!(importers_of_d, vec![a, b, c]);
3803        // Sort invariant: Vec is ascending by raw index.
3804        let mut sorted = importers_of_d.clone();
3805        sorted.sort();
3806        assert_eq!(importers_of_d, sorted);
3807    }
3808
3809    #[test]
3810    fn reverse_import_index_filters_non_import_edges() {
3811        // A `Calls` edge from A into B must not contribute to B's reverse
3812        // import index. Only `EdgeKind::Imports` edges count.
3813        let (mut graph, files, nodes) = build_import_test_graph(&["a.rs", "b.rs"]);
3814        let (a, b) = (files[0], files[1]);
3815        graph.edges_mut().add_edge(
3816            nodes[0],
3817            nodes[1],
3818            EdgeKind::Calls {
3819                argument_count: 0,
3820                is_async: false,
3821                resolved_via: ResolvedVia::Direct,
3822            },
3823            a,
3824        );
3825        graph
3826            .edges_mut()
3827            .add_edge(nodes[0], nodes[1], EdgeKind::References, a);
3828
3829        assert!(
3830            graph.reverse_import_index(b).is_empty(),
3831            "non-Imports edges must not register as importers"
3832        );
3833    }
3834
3835    #[test]
3836    fn reverse_import_index_elides_self_imports() {
3837        // An Imports edge whose source and target are both in the same file
3838        // is a self-import; the caller's own file must not appear in its own
3839        // reverse index.
3840        let (mut graph, files, nodes) = build_import_test_graph(&["a.rs"]);
3841        let a = files[0];
3842        // Add a second node in the same file so we have a distinct source.
3843        let name2 = graph.strings_mut().intern("sym2").unwrap();
3844        let second_in_a = graph
3845            .nodes_mut()
3846            .alloc(crate::graph::unified::storage::arena::NodeEntry::new(
3847                crate::graph::unified::node::NodeKind::Function,
3848                name2,
3849                a,
3850            ))
3851            .unwrap();
3852        graph.rebuild_indices();
3853        add_import_edge(&mut graph, second_in_a, nodes[0], a);
3854
3855        assert!(
3856            graph.reverse_import_index(a).is_empty(),
3857            "self-imports must be elided from reverse index"
3858        );
3859    }
3860
3861    #[test]
3862    fn reverse_import_index_mixed_edge_kinds_counts_only_imports() {
3863        // Two files: A has both Calls and Imports edges into B. Reverse
3864        // index must return exactly [A] — the Calls edge contributes
3865        // nothing.
3866        let (mut graph, files, nodes) = build_import_test_graph(&["a.rs", "b.rs"]);
3867        let (a, b) = (files[0], files[1]);
3868        add_import_edge(&mut graph, nodes[0], nodes[1], a);
3869        graph.edges_mut().add_edge(
3870            nodes[0],
3871            nodes[1],
3872            EdgeKind::Calls {
3873                argument_count: 0,
3874                is_async: false,
3875                resolved_via: ResolvedVia::Direct,
3876            },
3877            a,
3878        );
3879
3880        assert_eq!(graph.reverse_import_index(b), vec![a]);
3881    }
3882
3883    #[test]
3884    fn reverse_import_index_uninitialized_file_returns_empty() {
3885        // Querying a FileId that is not registered in the graph must return
3886        // an empty Vec, not panic.
3887        let (graph, _, _) = build_import_test_graph(&["a.rs"]);
3888        let bogus = FileId::new(9999);
3889        assert!(
3890            graph.reverse_import_index(bogus).is_empty(),
3891            "unknown FileId must return empty Vec without panicking"
3892        );
3893    }
3894
3895    #[test]
3896    fn reverse_import_index_skips_tombstoned_source_nodes() {
3897        // In the incremental rebuild path the prior graph retains tombstoned
3898        // arena slots for nodes belonging to closure files that have been
3899        // removed but not yet fully compacted. reverse_import_index is
3900        // called on that prior graph to widen the closure, so its
3901        // tombstone guard (`let Some(source_entry) = self.nodes.get(...)`)
3902        // is a semantically important branch, not a theoretical one.
3903        // This test exercises it directly by tombstoning the source node of
3904        // an Imports edge and asserting the edge silently disappears from
3905        // the reverse index.
3906        let (mut graph, files, nodes) = build_import_test_graph(&["a.rs", "b.rs"]);
3907        let (a, b) = (files[0], files[1]);
3908        add_import_edge(&mut graph, nodes[0], nodes[1], a);
3909        // Sanity: before tombstoning, A shows up as the importer of B.
3910        assert_eq!(graph.reverse_import_index(b), vec![a]);
3911        // Tombstone the source node via the arena (generation bump). The
3912        // Imports edge still exists in the edge store but its source NodeId
3913        // is now stale; `nodes.get(edge_ref.source)` returns None and the
3914        // guard skips the edge.
3915        let removed = graph.nodes_mut().remove(nodes[0]);
3916        assert!(
3917            removed.is_some(),
3918            "arena.remove must succeed for a live node"
3919        );
3920        assert!(
3921            graph.nodes().get(nodes[0]).is_none(),
3922            "tombstoned lookup must return None"
3923        );
3924        assert!(
3925            graph.reverse_import_index(b).is_empty(),
3926            "Imports edges whose source is tombstoned must be silently skipped"
3927        );
3928    }
3929
3930    // ------------------------------------------------------------------
3931    // Task 4 Step 2 — CodeGraph::remove_file
3932    // ------------------------------------------------------------------
3933
3934    /// Seed a graph with 2 files × `per_file` nodes per file, plus a set
3935    /// of intra- and inter-file edges. Each call site produces a
3936    /// canonical topology so tests below can assert bit-level on edge
3937    /// survival. Returns `(graph, file_a, file_b, file_a_nodes,
3938    /// file_b_nodes)`.
3939    fn seed_two_file_graph(
3940        per_file: usize,
3941    ) -> (
3942        CodeGraph,
3943        crate::graph::unified::file::FileId,
3944        crate::graph::unified::file::FileId,
3945        Vec<NodeId>,
3946        Vec<NodeId>,
3947    ) {
3948        use crate::graph::unified::edge::EdgeKind;
3949        use crate::graph::unified::node::NodeKind;
3950        use crate::graph::unified::storage::arena::NodeEntry;
3951        use std::path::Path;
3952
3953        let mut graph = CodeGraph::new();
3954        let sym = graph.strings_mut().intern("sym").expect("intern");
3955        let file_a = graph
3956            .files_mut()
3957            .register(Path::new("/tmp/remove_file_test/a.rs"))
3958            .expect("register a");
3959        let file_b = graph
3960            .files_mut()
3961            .register(Path::new("/tmp/remove_file_test/b.rs"))
3962            .expect("register b");
3963
3964        let mut file_a_nodes = Vec::with_capacity(per_file);
3965        let mut file_b_nodes = Vec::with_capacity(per_file);
3966
3967        for _ in 0..per_file {
3968            let n = graph
3969                .nodes_mut()
3970                .alloc(NodeEntry::new(NodeKind::Function, sym, file_a))
3971                .expect("alloc a-node");
3972            file_a_nodes.push(n);
3973            graph.files_mut().record_node(file_a, n);
3974            graph
3975                .indices_mut()
3976                .add(n, NodeKind::Function, sym, None, file_a);
3977        }
3978        for _ in 0..per_file {
3979            let n = graph
3980                .nodes_mut()
3981                .alloc(NodeEntry::new(NodeKind::Function, sym, file_b))
3982                .expect("alloc b-node");
3983            file_b_nodes.push(n);
3984            graph.files_mut().record_node(file_b, n);
3985            graph
3986                .indices_mut()
3987                .add(n, NodeKind::Function, sym, None, file_b);
3988        }
3989
3990        // Intra-file edges inside each file: pairwise a[i] -> a[i+1],
3991        // b[i] -> b[i+1]. These are the ones that must die when the
3992        // corresponding file is removed.
3993        for i in 0..per_file.saturating_sub(1) {
3994            graph.edges_mut().add_edge(
3995                file_a_nodes[i],
3996                file_a_nodes[i + 1],
3997                EdgeKind::Calls {
3998                    argument_count: 0,
3999                    is_async: false,
4000                    resolved_via: ResolvedVia::Direct,
4001                },
4002                file_a,
4003            );
4004            graph.edges_mut().add_edge(
4005                file_b_nodes[i],
4006                file_b_nodes[i + 1],
4007                EdgeKind::Calls {
4008                    argument_count: 0,
4009                    is_async: false,
4010                    resolved_via: ResolvedVia::Direct,
4011                },
4012                file_b,
4013            );
4014        }
4015        // Cross-file edges: a[0] -> b[0], b[0] -> a[0]. Both must die
4016        // when *either* endpoint's file is removed (plan §F.2).
4017        graph.edges_mut().add_edge(
4018            file_a_nodes[0],
4019            file_b_nodes[0],
4020            EdgeKind::Calls {
4021                argument_count: 0,
4022                is_async: false,
4023                resolved_via: ResolvedVia::Direct,
4024            },
4025            file_a,
4026        );
4027        graph.edges_mut().add_edge(
4028            file_b_nodes[0],
4029            file_a_nodes[0],
4030            EdgeKind::Calls {
4031                argument_count: 0,
4032                is_async: false,
4033                resolved_via: ResolvedVia::Direct,
4034            },
4035            file_b,
4036        );
4037
4038        (graph, file_a, file_b, file_a_nodes, file_b_nodes)
4039    }
4040
4041    #[test]
4042    fn code_graph_remove_file_tombstones_all_per_file_nodes() {
4043        let (mut graph, file_a, _file_b, file_a_nodes, _file_b_nodes) = seed_two_file_graph(3);
4044
4045        let returned = graph.remove_file(file_a);
4046
4047        // Returned list must equal the original per-file bucket
4048        // membership, deterministically.
4049        let returned_set: std::collections::HashSet<NodeId> = returned.iter().copied().collect();
4050        let expected_set: std::collections::HashSet<NodeId> =
4051            file_a_nodes.iter().copied().collect();
4052        assert_eq!(
4053            returned_set, expected_set,
4054            "remove_file must return exactly the file_a nodes drained from the bucket"
4055        );
4056
4057        // Every returned NodeId is gone from the arena.
4058        for nid in &file_a_nodes {
4059            assert!(
4060                graph.nodes().get(*nid).is_none(),
4061                "node {nid:?} from removed file must be tombstoned in arena"
4062            );
4063        }
4064    }
4065
4066    #[test]
4067    fn code_graph_remove_file_invalidates_all_edges_sourced_or_targeted_at_removed_nodes() {
4068        use crate::graph::unified::edge::EdgeKind;
4069
4070        let (mut graph, file_a, _file_b, file_a_nodes, file_b_nodes) = seed_two_file_graph(3);
4071
4072        // Sanity: the seed has 2 intra-A edges, 2 intra-B edges, plus
4073        // 2 cross-file edges (a0↔b0).
4074        let before_delta = graph.edges().stats().forward.delta_edge_count;
4075        assert_eq!(
4076            before_delta, 6,
4077            "seed must produce 2 intra-A + 2 intra-B + 2 cross edges"
4078        );
4079
4080        let _ = graph.remove_file(file_a);
4081
4082        // Forward delta after removal: all 2 intra-A edges are gone,
4083        // both cross edges are gone, only the 2 intra-B edges remain.
4084        let after_delta_forward = graph.edges().stats().forward.delta_edge_count;
4085        assert_eq!(
4086            after_delta_forward, 2,
4087            "only intra-B forward edges must remain after removing file_a"
4088        );
4089        let after_delta_reverse = graph.edges().stats().reverse.delta_edge_count;
4090        assert_eq!(
4091            after_delta_reverse, 2,
4092            "only intra-B reverse edges must remain after removing file_a"
4093        );
4094
4095        // Cross-file edge from b[0] -> a[0] must no longer be visible
4096        // from either direction, because a[0] is tombstoned.
4097        let b0 = file_b_nodes[0];
4098        let a0 = file_a_nodes[0];
4099        let remaining_from_b0: Vec<_> = graph
4100            .edges()
4101            .edges_from(b0)
4102            .into_iter()
4103            .filter(|e| {
4104                matches!(
4105                    e.kind,
4106                    EdgeKind::Calls {
4107                        argument_count: 0,
4108                        is_async: false,
4109                        resolved_via: ResolvedVia::Direct,
4110                    }
4111                )
4112            })
4113            .collect();
4114        assert!(
4115            !remaining_from_b0.iter().any(|e| e.target == a0),
4116            "edge b0 -> a0 must be gone after remove_file(file_a)"
4117        );
4118        let remaining_to_a0: Vec<_> = graph.edges().edges_to(a0).into_iter().collect();
4119        assert!(
4120            remaining_to_a0.is_empty(),
4121            "every edge targeting the tombstoned a0 must be gone"
4122        );
4123    }
4124
4125    #[test]
4126    fn code_graph_remove_file_drops_file_registry_entry() {
4127        let (mut graph, file_a, _file_b, _, _) = seed_two_file_graph(2);
4128
4129        assert!(
4130            graph.files().resolve(file_a).is_some(),
4131            "seed registered file_a"
4132        );
4133        assert!(
4134            !graph.files().nodes_for_file(file_a).is_empty(),
4135            "seed populated the file_a bucket"
4136        );
4137
4138        let _ = graph.remove_file(file_a);
4139
4140        assert!(
4141            graph.files().resolve(file_a).is_none(),
4142            "FileRegistry::resolve must return None after remove_file"
4143        );
4144        assert!(
4145            graph.files().nodes_for_file(file_a).is_empty(),
4146            "per-file bucket for file_a must be drained"
4147        );
4148    }
4149
4150    #[test]
4151    fn code_graph_remove_file_is_idempotent_on_unknown_file() {
4152        use crate::graph::unified::file::FileId;
4153        let (mut graph, _file_a, _file_b, _, _) = seed_two_file_graph(2);
4154
4155        // Snapshot state before idempotent no-op.
4156        let nodes_before = graph.nodes().len();
4157        let delta_fwd_before = graph.edges().stats().forward.delta_edge_count;
4158        let delta_rev_before = graph.edges().stats().reverse.delta_edge_count;
4159        let files_before = graph.files().len();
4160
4161        // A FileId that was never registered: the caller may legitimately
4162        // receive a bogus id from stale indexing state. `remove_file`
4163        // must be a silent no-op.
4164        let bogus = FileId::new(9999);
4165        let returned = graph.remove_file(bogus);
4166        assert!(
4167            returned.is_empty(),
4168            "remove_file on unknown FileId must return an empty Vec"
4169        );
4170
4171        assert_eq!(graph.nodes().len(), nodes_before, "arena count unchanged");
4172        assert_eq!(
4173            graph.edges().stats().forward.delta_edge_count,
4174            delta_fwd_before,
4175            "forward delta unchanged"
4176        );
4177        assert_eq!(
4178            graph.edges().stats().reverse.delta_edge_count,
4179            delta_rev_before,
4180            "reverse delta unchanged"
4181        );
4182        assert_eq!(graph.files().len(), files_before, "file count unchanged");
4183    }
4184
4185    #[test]
4186    fn code_graph_remove_file_clears_file_segments_entry() {
4187        // Iter-1 Codex review fix: a file's `FileSegmentTable` entry
4188        // must be cleared on `remove_file`. Without this, a later
4189        // `FileId` recycle (via `FileRegistry::free_list`) would
4190        // inherit the previous file's stale node-range and
4191        // `reindex_files` (`build/reindex.rs`) would tombstone the
4192        // wrong slots. This unit test seeds a segment entry, removes
4193        // the file, and asserts the entry is gone.
4194        use crate::graph::unified::storage::segment::FileSegmentTable;
4195
4196        let (mut graph, file_a, _file_b, file_a_nodes, _file_b_nodes) = seed_two_file_graph(3);
4197
4198        // Seed a segment for file A. Production code sets this via
4199        // Phase 3 parallel commit; here we go through the crate-
4200        // internal accessor because we are a unit test inside the
4201        // crate. (The feature-gated `test_only_record_file_segment`
4202        // public helper exists for the integration tests in
4203        // `sqry-core/tests/incremental_remove_file_scale.rs`.)
4204        let first_index = file_a_nodes
4205            .iter()
4206            .map(|n| n.index())
4207            .min()
4208            .expect("per_file = 3");
4209        let last_index = file_a_nodes
4210            .iter()
4211            .map(|n| n.index())
4212            .max()
4213            .expect("per_file = 3");
4214        let slot_count = last_index - first_index + 1;
4215        let table: &mut FileSegmentTable = graph.file_segments_mut();
4216        table.record_range(file_a, first_index, slot_count);
4217        assert!(
4218            graph.file_segments().get(file_a).is_some(),
4219            "seed must install a segment for file_a before remove_file"
4220        );
4221
4222        // Remove the file.
4223        let _ = graph.remove_file(file_a);
4224
4225        // The segment entry must be gone. `FileSegmentTable::remove`
4226        // is idempotent (a no-op on unknown ids), so this assertion
4227        // holds whether or not the seeded range was contiguous.
4228        assert!(
4229            graph.file_segments().get(file_a).is_none(),
4230            "remove_file must clear the FileSegmentTable entry for file_a"
4231        );
4232    }
4233
4234    #[test]
4235    fn code_graph_remove_file_repeated_calls_are_idempotent() {
4236        let (mut graph, file_a, _file_b, file_a_nodes, _file_b_nodes) = seed_two_file_graph(3);
4237
4238        // First call does the work.
4239        let first = graph.remove_file(file_a);
4240        assert_eq!(first.len(), file_a_nodes.len());
4241
4242        // Snapshot post-first-call state.
4243        let nodes_after = graph.nodes().len();
4244        let delta_fwd_after = graph.edges().stats().forward.delta_edge_count;
4245        let delta_rev_after = graph.edges().stats().reverse.delta_edge_count;
4246        let files_after = graph.files().len();
4247
4248        // Second call must be a silent no-op — the bucket is empty,
4249        // the file is unregistered, and the arena slots are already
4250        // tombstoned (NodeArena::remove ignores stale generations).
4251        let second = graph.remove_file(file_a);
4252        assert!(
4253            second.is_empty(),
4254            "second remove_file on the same file must return an empty Vec"
4255        );
4256
4257        assert_eq!(graph.nodes().len(), nodes_after);
4258        assert_eq!(
4259            graph.edges().stats().forward.delta_edge_count,
4260            delta_fwd_after
4261        );
4262        assert_eq!(
4263            graph.edges().stats().reverse.delta_edge_count,
4264            delta_rev_after
4265        );
4266        assert_eq!(graph.files().len(), files_after);
4267    }
4268}