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}