Skip to main content

sqry_core/graph/unified/storage/
metadata.rs

1//! Sparse node metadata store for macro boundary analysis, classpath
2//! provenance, and payload-less marker flags.
3//!
4//! This module provides [`NodeMetadataStore`], a sparse metadata store keyed by
5//! full [`NodeId`] (index + generation) to prevent stale metadata when the
6//! generational arena reuses a slot index with a new generation.
7//!
8//! # Two channels
9//!
10//! Each stored entry carries two independent channels:
11//!
12//! 1. **Typed payload** ([`TypedMetadata`]) — mutually-exclusive payload-bearing
13//!    metadata (today: macro vs. classpath provenance). A node has at most one
14//!    typed payload at a time.
15//! 2. **Marker flags** ([`NodeFlags`]) — payload-less, independently composable
16//!    boolean attributes (today: synthetic, address-taken, callsite-promiscuous).
17//!    Any subset may be set simultaneously, AND may co-occur with a typed
18//!    payload — e.g. a Rust function generated by a macro that is ALSO
19//!    address-taken via `&foo` is `TypedMetadata::Macro(_)` PLUS
20//!    `NodeFlags::ADDRESS_TAKEN`.
21//!
22//! Only nodes with metadata get entries, keeping memory overhead proportional
23//! to the number of annotated symbols rather than total node count.
24
25use std::collections::{BTreeMap, HashMap};
26
27use serde::{Deserialize, Serialize};
28
29use super::super::node::id::NodeId;
30use super::dispatch_tables::DispatchTables;
31use super::framework_routes::{FrameworkRouteMetadata, FrameworkRoutesMap};
32use super::shape::ShapeDescriptor;
33
34/// Optional metadata for nodes that participate in macro boundary analysis.
35///
36/// Stored separately from [`NodeEntry`] to avoid bloating the arena for the
37/// majority of nodes that don't need macro metadata.
38///
39/// Each field is `Option` (or `Vec` with empty default) so only relevant
40/// metadata consumes space in the serialized representation.
41#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
42pub struct MacroNodeMetadata {
43    /// Whether this symbol was generated by macro expansion.
44    pub macro_generated: Option<bool>,
45
46    /// Qualified name of the macro that generated this symbol.
47    pub macro_source: Option<String>,
48
49    /// The cfg predicate string (e.g., `"test"`, `"feature = \"serde\""`).
50    ///
51    /// **Language-agnostic.** Despite the enclosing struct name
52    /// (`MacroNodeMetadata`), `cfg_condition` is the canonical slot for
53    /// any conditional-compilation guard the language has:
54    ///
55    /// - Rust: `#[cfg(...)]` predicate string from `cfg_analysis`
56    ///   (e.g. `"target_os = \"linux\""`,
57    ///   `"all(target_os = \"linux\", target_arch = \"amd64\")"`).
58    /// - Go: file-level build constraint canonicalised by the
59    ///   Go plugin's `build_constraints` module (e.g. `"linux"`,
60    ///   `"linux && amd64"`, `"!windows"`, `"cgo"`). Per `01_SPEC` §3.3
61    ///   and `02_DESIGN` §3.3 (T3.8), Go build tags are file-level —
62    ///   every non-synthetic node staged from the same file shares the
63    ///   same `cfg_condition` string.
64    /// - Other languages: equivalent file-level / item-level
65    ///   conditional-compilation guards may use the same slot. The
66    ///   stored string is whatever canonical form the plugin chose;
67    ///   cross-language structural comparison is handled by sqry-db's
68    ///   `cfg_match` comparator (`02_DESIGN` §5.3.a).
69    ///
70    /// `None` means "no conditional-compilation guard recorded for this
71    /// node" (the default for plain Rust items without `#[cfg]` and Go
72    /// files without `//go:build`, `// +build`, recognised filename
73    /// suffix, or `import "C"`).
74    pub cfg_condition: Option<String>,
75
76    /// Whether this cfg is active (`None` = unknown, requires external config).
77    pub cfg_active: Option<bool>,
78
79    /// Proc-macro kind for proc-macro function nodes.
80    pub proc_macro_kind: Option<ProcMacroFunctionKind>,
81
82    /// Whether expansion data came from cache vs live `cargo expand`.
83    pub expansion_cached: Option<bool>,
84
85    /// Unresolved attribute paths that could not be positively identified
86    /// as proc-macro attributes. Stored for potential future resolution.
87    pub unresolved_attributes: Vec<String>,
88}
89
90/// Classification of proc-macro function types.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum ProcMacroFunctionKind {
94    /// `#[proc_macro_derive(Name)]` — generates impls for structs/enums.
95    Derive,
96    /// `#[proc_macro_attribute]` — transforms annotated items.
97    Attribute,
98    /// `#[proc_macro]` — function-like `my_macro!(...)` invocation.
99    FunctionLike,
100}
101
102/// Metadata for nodes originating from JVM classpath bytecode.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct ClasspathNodeMetadata {
105    /// Maven coordinates (e.g., `"com.google.guava:guava:33.0.0"`).
106    pub coordinates: Option<String>,
107    /// JAR file path this node was extracted from.
108    pub jar_path: String,
109    /// Fully qualified name in JVM format (e.g., `"java.util.HashMap"`).
110    pub fqn: String,
111    /// Whether this is a direct or transitive dependency.
112    pub is_direct_dependency: bool,
113}
114
115/// Mutually-exclusive payload-bearing metadata variants.
116///
117/// A node has at most one `TypedMetadata` at a time (Macro is Rust-only,
118/// Classpath is JVM-only — they cannot co-occur by language). Independent
119/// boolean attributes are carried by [`NodeFlags`] instead.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum TypedMetadata {
122    /// Rust macro-related metadata.
123    Macro(MacroNodeMetadata),
124    /// JVM classpath provenance metadata.
125    Classpath(ClasspathNodeMetadata),
126}
127
128/// Payload-less marker flags, independently composable.
129///
130/// Each flag is a single bit in a `u8` bitset. Flags compose freely with each
131/// other AND with the [`TypedMetadata`] channel — e.g. a node may carry
132/// `TypedMetadata::Macro(_)` AND `NodeFlags::SYNTHETIC | NodeFlags::ADDRESS_TAKEN`
133/// simultaneously.
134#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
135#[repr(transparent)]
136pub struct NodeFlags(u8);
137
138impl NodeFlags {
139    /// Synthetic placeholder node marker (`C_SUPPRESS`).
140    ///
141    /// Identifies internal-use-only nodes that language plugins emit as
142    /// shadows / scaffolding for binding-plane analysis (e.g. the Go
143    /// plugin's `<field:operand.field>` field-access placeholders and the
144    /// `name@<offset>` per-binding-site Variable nodes from the local-scope
145    /// resolver). These nodes must be suppressed from user-facing search
146    /// surfaces but remain reachable to internal callers via the explicit
147    /// `include_synthetic` opt-in path.
148    pub const SYNTHETIC: NodeFlags = NodeFlags(1 << 0);
149
150    /// Function whose address has been taken at some site in the workspace
151    /// (e.g. `&foo`, passed as an argument, stored in a function-pointer
152    /// field). Populated by the C plugin's address-taken classifier; consumed
153    /// by the C indirect-call resolver to scope type-signature matches.
154    pub const ADDRESS_TAKEN: NodeFlags = NodeFlags(1 << 1);
155
156    /// Call-site for which the indirect-call resolver exceeded the
157    /// per-callsite cardinality cap. The original `Calls`-stub edge is
158    /// preserved (no per-callee edges emitted) and the caller is flagged
159    /// so planners and consumers can surface the over-cap fan-out.
160    pub const CALLSITE_PROMISCUOUS: NodeFlags = NodeFlags(1 << 2);
161
162    /// Empty bitset.
163    pub const EMPTY: NodeFlags = NodeFlags(0);
164
165    /// Returns `true` iff every bit in `other` is set in `self`.
166    #[must_use]
167    pub const fn contains(self, other: NodeFlags) -> bool {
168        (self.0 & other.0) == other.0
169    }
170
171    /// Set every bit in `other`.
172    pub fn insert(&mut self, other: NodeFlags) {
173        self.0 |= other.0;
174    }
175
176    /// Clear every bit in `other`.
177    pub fn remove(&mut self, other: NodeFlags) {
178        self.0 &= !other.0;
179    }
180
181    /// Returns `true` iff no bits are set.
182    #[must_use]
183    pub const fn is_empty(self) -> bool {
184        self.0 == 0
185    }
186
187    /// Raw byte value (used by the wire format).
188    #[must_use]
189    pub const fn bits(self) -> u8 {
190        self.0
191    }
192
193    /// Construct from a raw byte value (used by the wire format).
194    #[must_use]
195    pub const fn from_bits(bits: u8) -> NodeFlags {
196        NodeFlags(bits)
197    }
198}
199
200impl std::ops::BitOr for NodeFlags {
201    type Output = NodeFlags;
202    fn bitor(self, rhs: NodeFlags) -> NodeFlags {
203        NodeFlags(self.0 | rhs.0)
204    }
205}
206
207impl std::ops::BitOrAssign for NodeFlags {
208    fn bitor_assign(&mut self, rhs: NodeFlags) {
209        self.0 |= rhs.0;
210    }
211}
212
213/// Per-`NodeId` metadata entry: one typed payload slot + one flag bitset.
214///
215/// The two channels are independent — `mark_*` methods on
216/// [`NodeMetadataStore`] update `flags` without disturbing `typed`, and
217/// `insert_typed` updates `typed` without disturbing `flags`. This is what
218/// enables co-occurrence of marker bits with typed payloads (e.g. a
219/// macro-generated function whose address has been taken).
220#[derive(Debug, Clone, Default, PartialEq, Eq)]
221pub struct StoredEntry {
222    /// Mutually-exclusive payload-bearing metadata. `None` when the node
223    /// only carries marker flags (no macro / classpath provenance).
224    pub typed: Option<TypedMetadata>,
225    /// Independently-composable marker flags.
226    pub flags: NodeFlags,
227}
228
229impl StoredEntry {
230    /// Construct from a typed payload with empty flags.
231    #[must_use]
232    pub fn with_typed(typed: TypedMetadata) -> StoredEntry {
233        StoredEntry {
234            typed: Some(typed),
235            flags: NodeFlags::EMPTY,
236        }
237    }
238
239    /// Construct from flag bits with no typed payload.
240    #[must_use]
241    pub fn with_flags(flags: NodeFlags) -> StoredEntry {
242        StoredEntry { typed: None, flags }
243    }
244
245    /// Returns `true` iff this entry has neither typed payload nor any flags set.
246    #[must_use]
247    pub fn is_vacant(&self) -> bool {
248        self.typed.is_none() && self.flags.is_empty()
249    }
250}
251
252/// Sparse metadata store keyed by full `NodeId` (index + generation).
253///
254/// Uses `(u32, u64)` tuple key to prevent stale metadata when the
255/// generational arena reuses a slot index with a new generation.
256/// A lookup with `NodeId { index: 5, generation: 3 }` will NOT match metadata
257/// stored for `NodeId { index: 5, generation: 2 }`.
258///
259/// # Memory characteristics
260///
261/// For a typical large codebase (100K nodes), only ~5-10% of nodes have
262/// metadata. A store with 10K entries at ~200 bytes each = ~2MB, which is
263/// acceptable given snapshots are already 10-50MB.
264///
265/// # Serialization
266///
267/// The in-memory representation uses `HashMap` for O(1) lookups. For postcard
268/// serialization (which doesn't support tuple keys natively), we serialize as
269/// a `Vec` of [`NodeMetadataEntryV11`] structs with explicit `index`,
270/// `generation`, `kind`, payload-slots, and `flags`, then reconstruct the
271/// `HashMap` on deserialization.
272///
273/// The Phase β joint-stub fields ([`Self::framework_routes`] +
274/// [`Self::dispatch_tables`]) are **not** carried by the in-store custom
275/// serde impl — they ride the V12 snapshot envelope as separate slots and
276/// are reattached via [`Self::set_framework_routes`] /
277/// [`Self::set_dispatch_tables`] on load. Keeping them outside the entry
278/// wire format preserves V11 metadata-store wire decoding when a V11
279/// snapshot is upconverted in place.
280#[derive(Debug, Clone, Default)]
281pub struct NodeMetadataStore {
282    /// Metadata entries keyed by `(NodeId::index(), NodeId::generation())`.
283    entries: HashMap<(u32, u64), StoredEntry>,
284    /// Plan A (V12, joint-stubs) — per-node framework-route metadata,
285    /// populated by Phase 4f's framework extractors. Empty in the stub.
286    framework_routes: FrameworkRoutesMap,
287    /// Plan B (V12, joint-stubs) — per-snapshot dispatch-resolution side
288    /// tables, populated by WS2 resolvers. Empty in the stub.
289    dispatch_tables: DispatchTables,
290    /// Identifier-blind per-function body-shape descriptors (V15), keyed by
291    /// full `NodeId`.
292    ///
293    /// Unlike `framework_routes` / `dispatch_tables` (which are empty stubs
294    /// reattached only at load time), this map IS populated during language
295    /// staging and rides the existing take -> rekey -> merge metadata
296    /// pipeline. Every `NodeId`-lifecycle and accounting hook on this type
297    /// therefore has to carry it: the emptiness contract ([`Self::is_empty`] /
298    /// [`Self::len`]), the staging->arena rekey
299    /// (`build::parallel_commit::rekey_staging_metadata_to_arena`), the Phase
300    /// 4c-prime loser drop (`build::unification::NodeRemapTable::apply_to_metadata_store`),
301    /// the incremental-rebuild prune ([`Self::retain_entries`]), the
302    /// staged->canonical [`Self::merge`], [`PartialEq`], `heap_bytes`, and the
303    /// `NodeIdBearing::all_node_ids` union in `rebuild::coverage`. A miss in any
304    /// one silently no-ops the feature or strands a descriptor on a tombstoned
305    /// node.
306    ///
307    /// It is NOT carried by the custom `entries` serde (which writes only
308    /// `entries`); the V15 snapshot envelope carries it in a dedicated slot,
309    /// reattached via [`Self::set_shape_descriptors`] (the `framework_routes`
310    /// envelope-slot precedent). `BTreeMap` (not `HashMap`) for deterministic
311    /// serialization order (AC-1 / AC-8).
312    shape_descriptors: BTreeMap<NodeId, ShapeDescriptor>,
313}
314
315/// Discriminant values for the on-wire `kind` byte.
316const TYPED_KIND_NONE: u8 = 0;
317const TYPED_KIND_MACRO: u8 = 1;
318const TYPED_KIND_CLASSPATH: u8 = 2;
319
320/// V11 wire-format entry for a single metadata record.
321///
322/// Adds `flags: u8` after the V7 layout. The `kind` byte now describes the
323/// typed payload only (Macro / Classpath / None — synthetic moved to `flags`).
324#[derive(Debug, Clone, Serialize, Deserialize)]
325struct NodeMetadataEntryV11 {
326    index: u32,
327    generation: u64,
328    /// Typed-payload discriminant: 0 = None, 1 = Macro, 2 = Classpath.
329    kind: u8,
330    /// Macro payload (present when `kind == TYPED_KIND_MACRO`).
331    macro_data: Option<MacroNodeMetadata>,
332    /// Classpath payload (present when `kind == TYPED_KIND_CLASSPATH`).
333    classpath_data: Option<ClasspathNodeMetadata>,
334    /// Marker-flag bitset (raw [`NodeFlags`] byte).
335    flags: u8,
336}
337
338// Legacy V7 / V10 wire-format entries (`NodeMetadataEntryV7Legacy` +
339// `LEGACY_V7_KIND_*` constants) moved into
340// `sqry-core/src/graph/unified/persistence/legacy_v10.rs` in U03 codex
341// iter-1, where they are owned by the versioned V10 wire-type module
342// alongside the `EdgeKindV10` mirror. The codex review flagged the
343// duplicate definition here as dead code; the canonical home is now the
344// persistence/legacy_v10 module, which is where the V10 → V11 upconvert
345// translates them into the live `StoredEntry { typed, flags }` shape.
346
347impl Serialize for NodeMetadataStore {
348    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
349        let entries: Vec<NodeMetadataEntryV11> = self
350            .entries
351            .iter()
352            .map(|(&(index, generation), stored)| {
353                let (kind, macro_data, classpath_data) = match &stored.typed {
354                    None => (TYPED_KIND_NONE, None, None),
355                    Some(TypedMetadata::Macro(m)) => (TYPED_KIND_MACRO, Some(m.clone()), None),
356                    Some(TypedMetadata::Classpath(c)) => {
357                        (TYPED_KIND_CLASSPATH, None, Some(c.clone()))
358                    }
359                };
360                NodeMetadataEntryV11 {
361                    index,
362                    generation,
363                    kind,
364                    macro_data,
365                    classpath_data,
366                    flags: stored.flags.bits(),
367                }
368            })
369            .collect();
370        entries.serialize(serializer)
371    }
372}
373
374impl<'de> Deserialize<'de> for NodeMetadataStore {
375    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
376        let entries: Vec<NodeMetadataEntryV11> = Vec::deserialize(deserializer)?;
377        let mut map = HashMap::with_capacity(entries.len());
378        for e in entries {
379            let typed = match e.kind {
380                TYPED_KIND_NONE => None,
381                TYPED_KIND_MACRO => Some(TypedMetadata::Macro(e.macro_data.unwrap_or_default())),
382                TYPED_KIND_CLASSPATH => {
383                    let data = e.classpath_data.ok_or_else(|| {
384                        serde::de::Error::custom(
385                            "missing classpath_data for Classpath typed metadata entry",
386                        )
387                    })?;
388                    Some(TypedMetadata::Classpath(data))
389                }
390                other => {
391                    return Err(serde::de::Error::custom(format!(
392                        "unknown typed-metadata kind discriminant {other}"
393                    )));
394                }
395            };
396            let stored = StoredEntry {
397                typed,
398                flags: NodeFlags::from_bits(e.flags),
399            };
400            map.insert((e.index, e.generation), stored);
401        }
402        // Phase β joint-stubs: `framework_routes` and `dispatch_tables`
403        // are NOT carried through the metadata-store custom serde wire
404        // (V11 metadata-store payloads must continue to decode bit-for-bit
405        // identically). The V12 snapshot envelope carries the new side
406        // tables in dedicated slots and the loader reattaches them via
407        // `Self::set_framework_routes` / `Self::set_dispatch_tables`
408        // after this deserialization completes. Default to empty here.
409        Ok(Self {
410            entries: map,
411            framework_routes: FrameworkRoutesMap::default(),
412            dispatch_tables: DispatchTables::default(),
413            // Envelope slot, same as the two stubs above: the V15 loader
414            // reattaches the descriptors via `set_shape_descriptors` after
415            // this entry-vec deserialization. Default to empty here.
416            shape_descriptors: BTreeMap::new(),
417        })
418    }
419}
420
421impl NodeMetadataStore {
422    /// Create a new empty metadata store.
423    #[must_use]
424    pub fn new() -> Self {
425        Self::default()
426    }
427
428    // ---------------------------------------------------------------
429    // Typed-payload accessors
430    // ---------------------------------------------------------------
431
432    /// Borrowed access to the typed payload for a node, if any.
433    ///
434    /// Returns `None` when the node has only marker flags or no entry at
435    /// all. Stale (generation-mismatched) `NodeId`s also return `None`.
436    #[must_use]
437    pub fn get_typed(&self, node_id: NodeId) -> Option<&TypedMetadata> {
438        self.entries
439            .get(&(node_id.index(), node_id.generation()))?
440            .typed
441            .as_ref()
442    }
443
444    /// Mutable access to the typed payload for a node, if any.
445    pub fn get_typed_mut(&mut self, node_id: NodeId) -> Option<&mut TypedMetadata> {
446        self.entries
447            .get_mut(&(node_id.index(), node_id.generation()))?
448            .typed
449            .as_mut()
450    }
451
452    /// Convenience accessor for the [`TypedMetadata::Macro`] variant only.
453    ///
454    /// Replaces the legacy `get` accessor. Returns `None` for classpath
455    /// entries, marker-only entries, missing entries, and stale generations.
456    #[must_use]
457    pub fn get_macro(&self, node_id: NodeId) -> Option<&MacroNodeMetadata> {
458        match self.get_typed(node_id)? {
459            TypedMetadata::Macro(m) => Some(m),
460            TypedMetadata::Classpath(_) => None,
461        }
462    }
463
464    /// Mutable accessor for the [`TypedMetadata::Macro`] variant only.
465    pub fn get_macro_mut(&mut self, node_id: NodeId) -> Option<&mut MacroNodeMetadata> {
466        match self.get_typed_mut(node_id)? {
467            TypedMetadata::Macro(m) => Some(m),
468            TypedMetadata::Classpath(_) => None,
469        }
470    }
471
472    // ---------------------------------------------------------------
473    // Marker-flag accessors
474    // ---------------------------------------------------------------
475
476    /// Returns the marker-flag bitset (empty bitset when no entry exists).
477    ///
478    /// Cheap by-value `Copy` return — no borrow contention with
479    /// [`Self::get_typed`].
480    #[must_use]
481    pub fn get_flags(&self, node_id: NodeId) -> NodeFlags {
482        self.entries
483            .get(&(node_id.index(), node_id.generation()))
484            .map_or(NodeFlags::EMPTY, |e| e.flags)
485    }
486
487    /// Returns `true` iff the node has [`NodeFlags::SYNTHETIC`] set.
488    #[must_use]
489    pub fn is_synthetic(&self, node_id: NodeId) -> bool {
490        self.get_flags(node_id).contains(NodeFlags::SYNTHETIC)
491    }
492
493    /// Returns `true` iff the node has [`NodeFlags::ADDRESS_TAKEN`] set.
494    #[must_use]
495    pub fn is_address_taken(&self, node_id: NodeId) -> bool {
496        self.get_flags(node_id).contains(NodeFlags::ADDRESS_TAKEN)
497    }
498
499    /// Returns `true` iff the node has [`NodeFlags::CALLSITE_PROMISCUOUS`] set.
500    #[must_use]
501    pub fn is_callsite_promiscuous(&self, node_id: NodeId) -> bool {
502        self.get_flags(node_id)
503            .contains(NodeFlags::CALLSITE_PROMISCUOUS)
504    }
505
506    /// Set [`NodeFlags::SYNTHETIC`] on this node.
507    ///
508    /// Does NOT disturb any existing typed payload — a macro-generated node
509    /// that is also marked synthetic retains both channels.
510    pub fn mark_synthetic(&mut self, node_id: NodeId) {
511        self.set_flag(node_id, NodeFlags::SYNTHETIC);
512    }
513
514    /// Set [`NodeFlags::ADDRESS_TAKEN`] on this node.
515    ///
516    /// Does NOT disturb any existing typed payload — preserves Macro /
517    /// Classpath provenance for nodes that happen to be address-taken (e.g.
518    /// a `DEFINE_HANDLER(my_irq)` C function that is also `&my_irq`'d).
519    pub fn mark_address_taken(&mut self, node_id: NodeId) {
520        self.set_flag(node_id, NodeFlags::ADDRESS_TAKEN);
521    }
522
523    /// Set [`NodeFlags::CALLSITE_PROMISCUOUS`] on this node.
524    ///
525    /// Does NOT disturb any existing typed payload.
526    pub fn mark_callsite_promiscuous(&mut self, node_id: NodeId) {
527        self.set_flag(node_id, NodeFlags::CALLSITE_PROMISCUOUS);
528    }
529
530    fn set_flag(&mut self, node_id: NodeId, flag: NodeFlags) {
531        self.entries
532            .entry((node_id.index(), node_id.generation()))
533            .or_default()
534            .flags
535            .insert(flag);
536    }
537
538    // ---------------------------------------------------------------
539    // Insertion / removal
540    // ---------------------------------------------------------------
541
542    /// Insert macro metadata for a node, replacing any existing typed
543    /// payload at this `NodeId`. Marker flags on the existing entry are
544    /// preserved.
545    pub fn insert(&mut self, node_id: NodeId, metadata: MacroNodeMetadata) {
546        self.insert_typed(node_id, TypedMetadata::Macro(metadata));
547    }
548
549    /// Insert a typed payload for a node, replacing any existing typed
550    /// payload. Marker flags on the existing entry are preserved.
551    pub fn insert_typed(&mut self, node_id: NodeId, typed: TypedMetadata) {
552        let slot = self
553            .entries
554            .entry((node_id.index(), node_id.generation()))
555            .or_default();
556        slot.typed = Some(typed);
557    }
558
559    /// Insert a fully-formed [`StoredEntry`] for a node, replacing any
560    /// existing entry. Used by bulk-remap paths (e.g. classpath emitter
561    /// id remapping) and the snapshot upconvert path.
562    pub fn insert_entry(&mut self, node_id: NodeId, entry: StoredEntry) {
563        self.entries
564            .insert((node_id.index(), node_id.generation()), entry);
565    }
566
567    /// Get or insert a default macro-metadata entry for a node.
568    ///
569    /// If the entry doesn't exist, it's created with an empty
570    /// `MacroNodeMetadata` typed payload and empty flags.
571    ///
572    /// # Panics
573    ///
574    /// Panics if the existing entry has a non-Macro typed payload
575    /// (i.e. [`TypedMetadata::Classpath`]). Callers must not mix typed
576    /// payloads at the same key.
577    pub fn get_or_insert_default(&mut self, node_id: NodeId) -> &mut MacroNodeMetadata {
578        let slot = self
579            .entries
580            .entry((node_id.index(), node_id.generation()))
581            .or_insert_with(|| {
582                StoredEntry::with_typed(TypedMetadata::Macro(MacroNodeMetadata::default()))
583            });
584        if slot.typed.is_none() {
585            slot.typed = Some(TypedMetadata::Macro(MacroNodeMetadata::default()));
586        }
587        match slot.typed.as_mut() {
588            Some(TypedMetadata::Macro(m)) => m,
589            Some(TypedMetadata::Classpath(_)) => {
590                panic!("get_or_insert_default called on a Classpath typed metadata entry")
591            }
592            None => unreachable!("just populated above"),
593        }
594    }
595
596    /// Remove the entry for a node and return its macro payload, if any.
597    ///
598    /// Returns `None` if no entry existed, or if the entry's typed payload
599    /// was not [`TypedMetadata::Macro`]. The entry (including any flags) is
600    /// removed in all cases when an entry was present.
601    pub fn remove(&mut self, node_id: NodeId) -> Option<MacroNodeMetadata> {
602        match self
603            .entries
604            .remove(&(node_id.index(), node_id.generation()))?
605            .typed
606        {
607            Some(TypedMetadata::Macro(m)) => Some(m),
608            Some(TypedMetadata::Classpath(_)) | None => None,
609        }
610    }
611
612    /// Remove the entry for a node and return the full [`StoredEntry`].
613    pub fn remove_entry(&mut self, node_id: NodeId) -> Option<StoredEntry> {
614        self.entries
615            .remove(&(node_id.index(), node_id.generation()))
616    }
617
618    // ---------------------------------------------------------------
619    // Iteration / bookkeeping
620    // ---------------------------------------------------------------
621
622    /// Returns the number of distinct nodes carrying any metadata: every
623    /// entry, plus shape-only descriptors whose `NodeId` has no entry.
624    ///
625    /// Counts the UNION (not the sum) so a node that carries both an entry and
626    /// a shape descriptor is counted once, keeping `len()` consistent with
627    /// [`Self::is_empty`] (`len() == 0` iff `is_empty()`). Most functions carry
628    /// a descriptor but no entry-metadata, so this is the common shape-only
629    /// case that the build-pipeline drop gates must NOT discard.
630    #[must_use]
631    pub fn len(&self) -> usize {
632        let shape_only = self
633            .shape_descriptors
634            .keys()
635            .filter(|nid| !self.entries.contains_key(&(nid.index(), nid.generation())))
636            .count();
637        self.entries.len() + shape_only
638    }
639
640    /// Returns true if no nodes have metadata.
641    ///
642    /// Load-bearing: the chunked build pipeline uses `is_empty()` as a DROP
643    /// gate (entrypoint, incremental, parallel-commit). A shape-only store
644    /// (descriptors but no `entries`, the common case for ordinary functions)
645    /// MUST report non-empty or it is silently discarded before the rekey +
646    /// merge, no-op'ing the whole feature. Hence the `shape_descriptors` term.
647    #[must_use]
648    pub fn is_empty(&self) -> bool {
649        self.entries.is_empty() && self.shape_descriptors.is_empty()
650    }
651
652    /// Iterate over entries whose typed payload is `Macro`, yielding the
653    /// `MacroNodeMetadata`. Entries that are flag-only, classpath, or
654    /// payload-less are skipped.
655    pub fn iter(&self) -> impl Iterator<Item = ((u32, u64), &MacroNodeMetadata)> {
656        self.entries.iter().filter_map(|(&k, v)| match &v.typed {
657            Some(TypedMetadata::Macro(m)) => Some((k, m)),
658            Some(TypedMetadata::Classpath(_)) | None => None,
659        })
660    }
661
662    /// Iterate over every stored entry as `(key, &StoredEntry)`.
663    ///
664    /// Replaces the legacy `iter_all` accessor that returned `&NodeMetadata`.
665    pub fn iter_entries(&self) -> impl Iterator<Item = ((u32, u64), &StoredEntry)> {
666        self.entries.iter().map(|(&k, v)| (k, v))
667    }
668
669    /// Merge another metadata store into this one.
670    ///
671    /// Entries from `other` overwrite existing entries with the same key. Shape
672    /// descriptors are merged identically (overwrite on key collision): unlike
673    /// the `framework_routes` / `dispatch_tables` stubs (which are reattached
674    /// only at load time and so are deliberately NOT merged here), shape
675    /// descriptors are produced per-file during staging and reach the
676    /// authoritative store through exactly this merge, so they must ride it.
677    pub fn merge(&mut self, other: &NodeMetadataStore) {
678        for (&key, value) in &other.entries {
679            self.entries.insert(key, value.clone());
680        }
681        for (&node_id, descriptor) in &other.shape_descriptors {
682            self.shape_descriptors.insert(node_id, descriptor.clone());
683        }
684    }
685
686    /// Retain only entries whose `(index, generation)` key satisfies `keep`.
687    ///
688    /// Used by the Gate 0b [`NodeIdBearing`] impl
689    /// (`sqry-core/src/graph/unified/rebuild/coverage.rs`) to drop
690    /// metadata for tombstoned `NodeIds` during
691    /// `RebuildGraph::finalize()`. Exposed at `pub(crate)` scope because
692    /// only the rebuild pipeline needs predicate-based filtering;
693    /// downstream callers use the targeted [`Self::remove`] /
694    /// [`Self::remove_entry`] entry points.
695    ///
696    /// `#[allow(dead_code)]` is present because Gate 0b delivers only
697    /// the scaffolding — the call sites in `RebuildGraph::finalize()`
698    /// (Gate 0c) and the residue check (Gate 0d) land in follow-up
699    /// commits. Unit coverage in
700    /// `sqry-core/src/graph/unified/rebuild/coverage.rs::tests` already
701    /// exercises this helper through the [`NodeIdBearing::retain_nodes`]
702    /// impl.
703    ///
704    /// [`NodeIdBearing`]: crate::graph::unified::rebuild::coverage::NodeIdBearing
705    /// [`NodeIdBearing::retain_nodes`]: crate::graph::unified::rebuild::coverage::NodeIdBearing::retain_nodes
706    #[allow(dead_code)]
707    pub(crate) fn retain_entries<F>(&mut self, mut keep: F)
708    where
709        F: FnMut(u32, u64) -> bool,
710    {
711        self.entries
712            .retain(|&(index, generation), _entry| keep(index, generation));
713        // Prune shape descriptors under the SAME predicate so a tombstoned
714        // node cannot leave a stranded descriptor behind. Without this the
715        // residue audit would flag a descriptor-only stale `NodeId`.
716        self.shape_descriptors
717            .retain(|node_id, _descriptor| keep(node_id.index(), node_id.generation()));
718    }
719
720    /// Test-only: clear the Phase A marker bits
721    /// ([`NodeFlags::ADDRESS_TAKEN`] and [`NodeFlags::CALLSITE_PROMISCUOUS`])
722    /// from every stored entry, leaving every other flag and the typed
723    /// payload untouched.
724    ///
725    /// Used exclusively by `sqry-core/tests/snapshot_size_phase_a.rs`
726    /// (U19) to materialize a "Phase-A-free" baseline snapshot for the
727    /// +10% snapshot-size gate.
728    ///
729    /// Gated behind `cfg(any(test, feature = "test-support"))` so the
730    /// helper is invisible to production builds and never accidentally
731    /// invoked from non-test surfaces.
732    #[cfg(any(test, feature = "test-support"))]
733    pub fn clear_phase_a_flags_for_test(&mut self) {
734        let mask = NodeFlags::ADDRESS_TAKEN | NodeFlags::CALLSITE_PROMISCUOUS;
735        for slot in self.entries.values_mut() {
736            slot.flags.remove(mask);
737        }
738    }
739
740    // ---------------------------------------------------------------
741    // Phase β joint-stubs: framework-routes + dispatch-tables
742    // ---------------------------------------------------------------
743    //
744    // The accessors below are the public surface for Plan A's framework
745    // route extractors and Plan B's dispatch resolvers. In this PR they
746    // are read-only-empty by default — no resolver populates them yet.
747    // The setters are used by the V12 snapshot load path to reattach the
748    // envelope slots after the metadata-store entries Vec has been
749    // deserialized.
750
751    /// Read-only access to the framework-route map (Plan A).
752    ///
753    /// Empty in the stub. Populated by Plan A's Phase 4f extractor pass
754    /// in the `feat/framework-route-extractors` downstream PR.
755    #[must_use]
756    pub fn framework_routes(&self) -> &FrameworkRoutesMap {
757        &self.framework_routes
758    }
759
760    /// Mutable access to the framework-route map (Plan A).
761    ///
762    /// Reserved for Phase 4f extractors. The MCP filter / planner predicate
763    /// that ship in this same PR read through [`Self::framework_routes`]
764    /// only.
765    pub fn framework_routes_mut(&mut self) -> &mut FrameworkRoutesMap {
766        &mut self.framework_routes
767    }
768
769    /// Replace the framework-route map wholesale.
770    ///
771    /// Used by the V12 snapshot loader to reattach the envelope slot to
772    /// the in-memory metadata store after entry-vec deserialization.
773    pub fn set_framework_routes(&mut self, routes: FrameworkRoutesMap) {
774        self.framework_routes = routes;
775    }
776
777    /// Lookup helper — returns the route metadata for a node if one was
778    /// recorded by a framework extractor.
779    #[must_use]
780    pub fn framework_route(&self, node_id: NodeId) -> Option<&FrameworkRouteMetadata> {
781        self.framework_routes.get(&node_id)
782    }
783
784    /// Read-only access to the dispatch-tables side store (Plan B).
785    ///
786    /// Empty in the stub. Populated by Plan B's WS2 resolver passes
787    /// (JVM virtual / interface, Go interface, Python duck-typed,
788    /// TypeScript structural, promiscuous-cap elision).
789    #[must_use]
790    pub fn dispatch_tables(&self) -> &DispatchTables {
791        &self.dispatch_tables
792    }
793
794    /// Mutable access to the dispatch-tables side store (Plan B).
795    pub fn dispatch_tables_mut(&mut self) -> &mut DispatchTables {
796        &mut self.dispatch_tables
797    }
798
799    /// Replace the dispatch-tables wholesale.
800    ///
801    /// Used by the V12 snapshot loader to reattach the envelope slot to
802    /// the in-memory metadata store after entry-vec deserialization.
803    pub fn set_dispatch_tables(&mut self, tables: DispatchTables) {
804        self.dispatch_tables = tables;
805    }
806
807    // ---------------------------------------------------------------
808    // Shape descriptors (V15 body-shape side table)
809    // ---------------------------------------------------------------
810
811    /// Insert (or replace) the shape descriptor for a node.
812    ///
813    /// Called from the build seam (`build::staging`) during staging, keyed by
814    /// the staging-local `NodeId`, and again by
815    /// `rekey_staging_metadata_to_arena` under the committed arena `NodeId`.
816    pub fn insert_shape_descriptor(&mut self, node_id: NodeId, descriptor: ShapeDescriptor) {
817        self.shape_descriptors.insert(node_id, descriptor);
818    }
819
820    /// Borrowed access to a node's shape descriptor, if one was computed.
821    #[must_use]
822    pub fn shape_descriptor(&self, node_id: NodeId) -> Option<&ShapeDescriptor> {
823        self.shape_descriptors.get(&node_id)
824    }
825
826    /// Read-only access to the whole shape-descriptor map.
827    ///
828    /// Used by the V15 snapshot writer to extract the envelope payload, by the
829    /// staging->arena rekey to iterate staging descriptors, and by the
830    /// structural-index / query surfaces downstream.
831    #[must_use]
832    pub fn shape_descriptors(&self) -> &BTreeMap<NodeId, ShapeDescriptor> {
833        &self.shape_descriptors
834    }
835
836    /// Remove a node's shape descriptor, returning it if present.
837    ///
838    /// Used by the Phase 4c-prime loser-drop
839    /// (`NodeRemapTable::apply_to_metadata_store`) to evict a tombstoned
840    /// loser's descriptor.
841    pub fn remove_shape_descriptor(&mut self, node_id: NodeId) -> Option<ShapeDescriptor> {
842        self.shape_descriptors.remove(&node_id)
843    }
844
845    /// Replace the shape-descriptor map wholesale.
846    ///
847    /// Used by the V15 snapshot loader to reattach the envelope slot after
848    /// entry-vec deserialization (the `set_framework_routes` precedent).
849    pub fn set_shape_descriptors(&mut self, descriptors: BTreeMap<NodeId, ShapeDescriptor>) {
850        self.shape_descriptors = descriptors;
851    }
852
853    /// Iterate the `NodeId`s that carry a shape descriptor.
854    ///
855    /// The `NodeIdBearing` impl in `rebuild::coverage` chains this with the
856    /// entry-derived `NodeId`s so the tombstone-residue audit sees descriptor-
857    /// only nodes too.
858    pub fn iter_shape_descriptor_node_ids(&self) -> impl Iterator<Item = NodeId> + '_ {
859        self.shape_descriptors.keys().copied()
860    }
861}
862
863impl PartialEq for NodeMetadataStore {
864    fn eq(&self, other: &Self) -> bool {
865        self.entries == other.entries
866            && self.framework_routes == other.framework_routes
867            && self.dispatch_tables == other.dispatch_tables
868            // AC-8 round-trip equality must not be blind to descriptor loss.
869            && self.shape_descriptors == other.shape_descriptors
870    }
871}
872
873impl Eq for NodeMetadataStore {}
874
875impl crate::graph::unified::memory::GraphMemorySize for NodeMetadataStore {
876    fn heap_bytes(&self) -> usize {
877        use crate::graph::unified::memory::HASHMAP_ENTRY_OVERHEAD;
878
879        let base = self.entries.capacity()
880            * (std::mem::size_of::<(u32, u64)>()
881                + std::mem::size_of::<StoredEntry>()
882                + HASHMAP_ENTRY_OVERHEAD);
883        // Account for heap Strings inside each typed payload. Marker-flag
884        // entries are payload-less — only the `flags` byte itself, which is
885        // counted via mem::size_of::<StoredEntry>() in `base`.
886        let inner: usize = self
887            .entries
888            .values()
889            .map(|entry| match &entry.typed {
890                None => 0,
891                Some(TypedMetadata::Macro(m)) => {
892                    m.macro_source.as_ref().map_or(0, String::capacity)
893                        + m.cfg_condition.as_ref().map_or(0, String::capacity)
894                        + m.unresolved_attributes
895                            .iter()
896                            .map(String::capacity)
897                            .sum::<usize>()
898                        + m.unresolved_attributes.capacity() * std::mem::size_of::<String>()
899                }
900                Some(TypedMetadata::Classpath(c)) => {
901                    c.coordinates.as_ref().map_or(0, String::capacity)
902                        + c.jar_path.capacity()
903                        + c.fqn.capacity()
904                }
905            })
906            .sum();
907        // Phase β joint-stubs: account for the framework-routes BTreeMap
908        // and the dispatch-tables side store. The stubs are empty by
909        // construction; this code accounts for whatever Plan A / Plan B
910        // populate downstream without needing a second size-impl edit.
911        let framework_routes_bytes = self.framework_routes.len()
912            * (std::mem::size_of::<NodeId>() + std::mem::size_of::<FrameworkRouteMetadata>());
913        // Phase β joint-stubs (V12 DispatchTables shape — Plan B DESIGN
914        // §3.7): five per-plane collections, each contributing
915        // `len * (NodeId + entry-type)` heap bytes. Empty until Plan B's
916        // resolver PRs (`U_WS2_2_*` ...) populate the planes.
917        let dt = &self.dispatch_tables;
918        let dispatch_tables_bytes = dt.jvm_virtual.len()
919            * (std::mem::size_of::<NodeId>()
920                + std::mem::size_of::<super::dispatch_tables::JvmDispatchEntry>())
921            + dt.go_interface.len()
922                * (std::mem::size_of::<NodeId>()
923                    + std::mem::size_of::<super::dispatch_tables::GoDispatchEntry>())
924            + dt.python_duck.len()
925                * (std::mem::size_of::<NodeId>()
926                    + std::mem::size_of::<super::dispatch_tables::PythonDispatchEntry>())
927            + dt.ts_structural.len()
928                * (std::mem::size_of::<NodeId>()
929                    + std::mem::size_of::<super::dispatch_tables::TsDispatchEntry>())
930            + dt.cap_hits.len() * std::mem::size_of::<super::dispatch_tables::CapHit>();
931        // V15 body-shape side table. `ShapeDescriptor` is fixed-size POD (no
932        // heap-allocated fields: the cf histogram and the MinHash lanes are
933        // inline arrays), so `size_of` captures the whole payload; charge the
934        // key alongside it, mirroring the framework-routes accounting above.
935        // This delta feeds `CodeGraph::heap_bytes` and the daemon
936        // admission/LRU memory budget, so a shape-only graph is accounted, not
937        // admitted past `memory_limit_mb` undercounted.
938        let shape_descriptors_bytes = self.shape_descriptors.len()
939            * (std::mem::size_of::<NodeId>() + std::mem::size_of::<ShapeDescriptor>());
940        base + inner + framework_routes_bytes + dispatch_tables_bytes + shape_descriptors_bytes
941    }
942}
943
944#[cfg(test)]
945mod tests {
946    use super::*;
947
948    #[test]
949    fn test_metadata_store_basic_operations() {
950        let mut store = NodeMetadataStore::new();
951        assert!(store.is_empty());
952        assert_eq!(store.len(), 0);
953
954        let node = NodeId::new(5, 1);
955        let metadata = MacroNodeMetadata {
956            macro_generated: Some(true),
957            macro_source: Some("derive_Debug".to_string()),
958            ..Default::default()
959        };
960
961        store.insert(node, metadata.clone());
962        assert_eq!(store.len(), 1);
963        assert!(!store.is_empty());
964
965        let retrieved = store.get_macro(node).unwrap();
966        assert_eq!(retrieved.macro_generated, Some(true));
967        assert_eq!(retrieved.macro_source.as_deref(), Some("derive_Debug"));
968    }
969
970    #[test]
971    fn test_metadata_full_nodeid_key() {
972        let mut store = NodeMetadataStore::new();
973
974        let node_gen1 = NodeId::new(5, 1);
975        let node_gen2 = NodeId::new(5, 2);
976
977        store.insert(
978            node_gen1,
979            MacroNodeMetadata {
980                macro_generated: Some(true),
981                ..Default::default()
982            },
983        );
984
985        // Same index, different generation → should NOT match
986        assert!(store.get_macro(node_gen2).is_none());
987
988        // Same index, same generation → should match
989        assert!(store.get_macro(node_gen1).is_some());
990    }
991
992    #[test]
993    fn test_metadata_slot_reuse_no_stale_data() {
994        let mut store = NodeMetadataStore::new();
995
996        // Simulate: node at index 5 gen 1 has metadata
997        let old_node = NodeId::new(5, 1);
998        store.insert(
999            old_node,
1000            MacroNodeMetadata {
1001                cfg_condition: Some("test".to_string()),
1002                ..Default::default()
1003            },
1004        );
1005
1006        // Simulate: slot 5 is reused with generation 2 (new node)
1007        let new_node = NodeId::new(5, 2);
1008
1009        // New node should NOT see old metadata
1010        assert!(store.get_macro(new_node).is_none());
1011
1012        // Old node still accessible
1013        assert_eq!(
1014            store.get_macro(old_node).unwrap().cfg_condition.as_deref(),
1015            Some("test")
1016        );
1017    }
1018
1019    #[test]
1020    fn test_metadata_store_postcard_roundtrip() {
1021        let mut store = NodeMetadataStore::new();
1022
1023        store.insert(
1024            NodeId::new(1, 0),
1025            MacroNodeMetadata {
1026                macro_generated: Some(true),
1027                macro_source: Some("derive_Debug".to_string()),
1028                cfg_condition: Some("test".to_string()),
1029                cfg_active: Some(true),
1030                proc_macro_kind: Some(ProcMacroFunctionKind::Derive),
1031                expansion_cached: Some(false),
1032                unresolved_attributes: vec!["my_attr".to_string()],
1033            },
1034        );
1035
1036        store.insert(
1037            NodeId::new(42, 3),
1038            MacroNodeMetadata {
1039                cfg_condition: Some("feature = \"serde\"".to_string()),
1040                ..Default::default()
1041            },
1042        );
1043
1044        let bytes = postcard::to_allocvec(&store).expect("serialize");
1045        let deserialized: NodeMetadataStore = postcard::from_bytes(&bytes).expect("deserialize");
1046
1047        assert_eq!(store, deserialized);
1048    }
1049
1050    #[test]
1051    fn test_empty_metadata_store_zero_overhead() {
1052        let store = NodeMetadataStore::new();
1053        let bytes = postcard::to_allocvec(&store).expect("serialize");
1054
1055        // Empty HashMap serializes to a single varint length of 0
1056        assert!(
1057            bytes.len() <= 2,
1058            "Empty store should serialize to minimal bytes, got {} bytes",
1059            bytes.len()
1060        );
1061    }
1062
1063    #[test]
1064    fn test_metadata_store_merge() {
1065        let mut store1 = NodeMetadataStore::new();
1066        let mut store2 = NodeMetadataStore::new();
1067
1068        store1.insert(
1069            NodeId::new(1, 0),
1070            MacroNodeMetadata {
1071                macro_generated: Some(true),
1072                ..Default::default()
1073            },
1074        );
1075
1076        store2.insert(
1077            NodeId::new(2, 0),
1078            MacroNodeMetadata {
1079                cfg_condition: Some("test".to_string()),
1080                ..Default::default()
1081            },
1082        );
1083
1084        store1.merge(&store2);
1085        assert_eq!(store1.len(), 2);
1086        assert!(store1.get_macro(NodeId::new(1, 0)).is_some());
1087        assert!(store1.get_macro(NodeId::new(2, 0)).is_some());
1088    }
1089
1090    #[test]
1091    fn test_proc_macro_function_kind_serde() {
1092        let kinds = [
1093            ProcMacroFunctionKind::Derive,
1094            ProcMacroFunctionKind::Attribute,
1095            ProcMacroFunctionKind::FunctionLike,
1096        ];
1097
1098        for kind in kinds {
1099            let bytes = postcard::to_allocvec(&kind).expect("serialize");
1100            let deserialized: ProcMacroFunctionKind =
1101                postcard::from_bytes(&bytes).expect("deserialize");
1102            assert_eq!(kind, deserialized);
1103        }
1104    }
1105
1106    #[test]
1107    fn test_metadata_get_or_insert_default() {
1108        let mut store = NodeMetadataStore::new();
1109        let node = NodeId::new(10, 0);
1110
1111        // First access creates default
1112        let meta = store.get_or_insert_default(node);
1113        meta.cfg_condition = Some("test".to_string());
1114
1115        // Second access returns existing
1116        let meta = store.get_macro(node).unwrap();
1117        assert_eq!(meta.cfg_condition.as_deref(), Some("test"));
1118    }
1119
1120    #[test]
1121    fn test_metadata_remove() {
1122        let mut store = NodeMetadataStore::new();
1123        let node = NodeId::new(1, 0);
1124
1125        store.insert(
1126            node,
1127            MacroNodeMetadata {
1128                macro_generated: Some(true),
1129                ..Default::default()
1130            },
1131        );
1132
1133        assert!(store.get_macro(node).is_some());
1134        let removed = store.remove(node);
1135        assert!(removed.is_some());
1136        assert!(store.get_macro(node).is_none());
1137        assert!(store.is_empty());
1138    }
1139
1140    #[test]
1141    fn test_metadata_store_large_scale() {
1142        let mut store = NodeMetadataStore::new();
1143
1144        // Insert 10K entries (simulating ~10% of a 100K-node codebase)
1145        for i in 0..10_000u32 {
1146            store.insert(
1147                NodeId::new(i, 0),
1148                MacroNodeMetadata {
1149                    cfg_condition: Some(format!("feature_{i}")),
1150                    ..Default::default()
1151                },
1152            );
1153        }
1154
1155        assert_eq!(store.len(), 10_000);
1156
1157        // Verify O(1) lookups
1158        assert!(store.get_macro(NodeId::new(0, 0)).is_some());
1159        assert!(store.get_macro(NodeId::new(5_000, 0)).is_some());
1160        assert!(store.get_macro(NodeId::new(9_999, 0)).is_some());
1161        assert!(store.get_macro(NodeId::new(10_000, 0)).is_none());
1162
1163        // Verify round-trip
1164        let bytes = postcard::to_allocvec(&store).expect("serialize");
1165        let deserialized: NodeMetadataStore = postcard::from_bytes(&bytes).expect("deserialize");
1166        assert_eq!(store, deserialized);
1167    }
1168
1169    #[test]
1170    fn test_classpath_metadata_insert_and_get() {
1171        let mut store = NodeMetadataStore::new();
1172        let node = NodeId::new(100, 0);
1173
1174        let cp_meta = ClasspathNodeMetadata {
1175            coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
1176            jar_path: "/home/user/.m2/repository/guava-33.0.0.jar".to_string(),
1177            fqn: "com.google.common.collect.ImmutableList".to_string(),
1178            is_direct_dependency: true,
1179        };
1180
1181        store.insert_typed(node, TypedMetadata::Classpath(cp_meta.clone()));
1182        assert_eq!(store.len(), 1);
1183
1184        // get_macro should return None (only returns Macro variant)
1185        assert!(store.get_macro(node).is_none());
1186
1187        // get_typed should return the classpath metadata
1188        let retrieved = store.get_typed(node).unwrap();
1189        match retrieved {
1190            TypedMetadata::Classpath(cp) => {
1191                assert_eq!(cp.fqn, "com.google.common.collect.ImmutableList");
1192                assert_eq!(
1193                    cp.coordinates.as_deref(),
1194                    Some("com.google.guava:guava:33.0.0")
1195                );
1196                assert!(cp.is_direct_dependency);
1197            }
1198            TypedMetadata::Macro(_) => panic!("expected Classpath variant"),
1199        }
1200    }
1201
1202    #[test]
1203    fn test_classpath_metadata_postcard_roundtrip() {
1204        let mut store = NodeMetadataStore::new();
1205
1206        // Mix of macro and classpath metadata
1207        store.insert(
1208            NodeId::new(1, 0),
1209            MacroNodeMetadata {
1210                macro_generated: Some(true),
1211                ..Default::default()
1212            },
1213        );
1214
1215        store.insert_typed(
1216            NodeId::new(2, 0),
1217            TypedMetadata::Classpath(ClasspathNodeMetadata {
1218                coordinates: Some("org.slf4j:slf4j-api:2.0.0".to_string()),
1219                jar_path: "slf4j-api-2.0.0.jar".to_string(),
1220                fqn: "org.slf4j.Logger".to_string(),
1221                is_direct_dependency: false,
1222            }),
1223        );
1224
1225        let bytes = postcard::to_allocvec(&store).expect("serialize");
1226        let deserialized: NodeMetadataStore = postcard::from_bytes(&bytes).expect("deserialize");
1227        assert_eq!(store, deserialized);
1228        assert_eq!(deserialized.len(), 2);
1229
1230        // Verify macro entry
1231        assert!(deserialized.get_macro(NodeId::new(1, 0)).is_some());
1232
1233        // Verify classpath entry via get_typed
1234        let cp = deserialized.get_typed(NodeId::new(2, 0)).unwrap();
1235        assert!(matches!(cp, TypedMetadata::Classpath(_)));
1236    }
1237
1238    #[test]
1239    fn test_node_metadata_store_json_roundtrip() {
1240        let mut store = NodeMetadataStore::new();
1241
1242        store.insert(
1243            NodeId::new(1, 0),
1244            MacroNodeMetadata {
1245                macro_generated: Some(true),
1246                macro_source: Some("serde_derive".to_string()),
1247                ..Default::default()
1248            },
1249        );
1250
1251        store.insert_typed(
1252            NodeId::new(2, 0),
1253            TypedMetadata::Classpath(ClasspathNodeMetadata {
1254                coordinates: None,
1255                jar_path: "rt.jar".to_string(),
1256                fqn: "java.lang.String".to_string(),
1257                is_direct_dependency: true,
1258            }),
1259        );
1260
1261        let json = serde_json::to_string(&store).unwrap();
1262        let deserialized: NodeMetadataStore = serde_json::from_str(&json).unwrap();
1263        assert_eq!(store, deserialized);
1264    }
1265
1266    #[test]
1267    fn test_iter_entries_includes_both_types() {
1268        let mut store = NodeMetadataStore::new();
1269
1270        store.insert(
1271            NodeId::new(1, 0),
1272            MacroNodeMetadata {
1273                macro_generated: Some(true),
1274                ..Default::default()
1275            },
1276        );
1277
1278        store.insert_typed(
1279            NodeId::new(2, 0),
1280            TypedMetadata::Classpath(ClasspathNodeMetadata {
1281                coordinates: None,
1282                jar_path: "test.jar".to_string(),
1283                fqn: "com.example.Test".to_string(),
1284                is_direct_dependency: true,
1285            }),
1286        );
1287
1288        // iter() only yields macro entries
1289        let macro_entries: Vec<_> = store.iter().collect();
1290        assert_eq!(macro_entries.len(), 1);
1291
1292        // iter_entries() yields all entries
1293        let all_entries: Vec<_> = store.iter_entries().collect();
1294        assert_eq!(all_entries.len(), 2);
1295    }
1296
1297    #[test]
1298    fn test_remove_entry_classpath() {
1299        let mut store = NodeMetadataStore::new();
1300        let node = NodeId::new(50, 0);
1301
1302        store.insert_typed(
1303            node,
1304            TypedMetadata::Classpath(ClasspathNodeMetadata {
1305                coordinates: None,
1306                jar_path: "test.jar".to_string(),
1307                fqn: "Test".to_string(),
1308                is_direct_dependency: true,
1309            }),
1310        );
1311
1312        assert_eq!(store.len(), 1);
1313
1314        // remove() returns None for non-macro entries, but still removes
1315        let removed = store.remove(node);
1316        assert!(removed.is_none());
1317        assert!(store.is_empty());
1318    }
1319
1320    #[test]
1321    fn test_remove_entry_typed() {
1322        let mut store = NodeMetadataStore::new();
1323        let node = NodeId::new(50, 0);
1324
1325        store.insert_typed(
1326            node,
1327            TypedMetadata::Classpath(ClasspathNodeMetadata {
1328                coordinates: None,
1329                jar_path: "test.jar".to_string(),
1330                fqn: "Test".to_string(),
1331                is_direct_dependency: true,
1332            }),
1333        );
1334
1335        // remove_entry() returns the full StoredEntry
1336        let removed = store.remove_entry(node);
1337        assert!(matches!(
1338            removed.as_ref().and_then(|e| e.typed.as_ref()),
1339            Some(TypedMetadata::Classpath(_))
1340        ));
1341        assert!(store.is_empty());
1342    }
1343
1344    // ------------------------------------------------------------------
1345    // V15 shape-descriptor side table: emptiness contract + lifecycle hooks
1346    // ------------------------------------------------------------------
1347
1348    mod shape_descriptor_tests {
1349        use super::*;
1350        use crate::graph::unified::build::shape::CfBucket;
1351        use crate::graph::unified::memory::GraphMemorySize;
1352        use crate::graph::unified::storage::shape::ShapeDescriptor;
1353
1354        fn descriptor_with_branches(n: u16) -> ShapeDescriptor {
1355            let mut d = ShapeDescriptor::default();
1356            d.cf_histogram[CfBucket::Branch.index()] = n;
1357            d
1358        }
1359
1360        #[test]
1361        fn shape_only_store_is_non_empty_and_counts() {
1362            // The single most dangerous miss: a store with descriptors but no
1363            // entries (the common case for ordinary functions) MUST report
1364            // non-empty so the build-pipeline drop gates do not discard it.
1365            let mut store = NodeMetadataStore::new();
1366            assert!(store.is_empty());
1367            assert_eq!(store.len(), 0);
1368
1369            store.insert_shape_descriptor(NodeId::new(7, 1), ShapeDescriptor::default());
1370            assert!(!store.is_empty(), "shape-only store must be non-empty");
1371            assert_eq!(store.len(), 1);
1372        }
1373
1374        #[test]
1375        fn len_counts_union_not_sum() {
1376            // A node carrying BOTH an entry and a descriptor is counted once,
1377            // keeping len() consistent with is_empty().
1378            let mut store = NodeMetadataStore::new();
1379            let both = NodeId::new(1, 1);
1380            store.insert(both, MacroNodeMetadata::default());
1381            store.insert_shape_descriptor(both, ShapeDescriptor::default());
1382            store.insert_shape_descriptor(NodeId::new(2, 1), ShapeDescriptor::default());
1383            // 1 node with an entry (also a descriptor) + 1 shape-only node = 2.
1384            assert_eq!(store.len(), 2);
1385        }
1386
1387        #[test]
1388        fn merge_carries_shape_descriptors() {
1389            let mut dst = NodeMetadataStore::new();
1390            let mut src = NodeMetadataStore::new();
1391            src.insert_shape_descriptor(NodeId::new(3, 1), descriptor_with_branches(5));
1392            dst.merge(&src);
1393            assert_eq!(
1394                dst.shape_descriptor(NodeId::new(3, 1))
1395                    .map(|d| d.cf_histogram[CfBucket::Branch.index()]),
1396                Some(5),
1397                "merge must carry shape descriptors into the authoritative store"
1398            );
1399        }
1400
1401        #[test]
1402        fn retain_entries_prunes_shape_descriptors() {
1403            let mut store = NodeMetadataStore::new();
1404            let keep = NodeId::new(10, 1);
1405            let drop = NodeId::new(11, 1);
1406            store.insert_shape_descriptor(keep, ShapeDescriptor::default());
1407            store.insert_shape_descriptor(drop, ShapeDescriptor::default());
1408            store.retain_entries(|index, _generation| index == 10);
1409            assert!(store.shape_descriptor(keep).is_some());
1410            assert!(
1411                store.shape_descriptor(drop).is_none(),
1412                "retain must prune descriptors under the same predicate"
1413            );
1414        }
1415
1416        #[test]
1417        fn partial_eq_is_sensitive_to_descriptor_loss() {
1418            let mut a = NodeMetadataStore::new();
1419            let mut b = NodeMetadataStore::new();
1420            a.insert_shape_descriptor(NodeId::new(4, 1), descriptor_with_branches(2));
1421            b.insert_shape_descriptor(NodeId::new(4, 1), descriptor_with_branches(9));
1422            assert_ne!(a, b, "equality must not be blind to descriptor differences");
1423            b.insert_shape_descriptor(NodeId::new(4, 1), descriptor_with_branches(2));
1424            assert_eq!(a, b);
1425        }
1426
1427        #[test]
1428        fn heap_bytes_grows_with_descriptors() {
1429            let store_empty = NodeMetadataStore::new();
1430            let base = store_empty.heap_bytes();
1431            let mut store = NodeMetadataStore::new();
1432            store.insert_shape_descriptor(NodeId::new(5, 1), ShapeDescriptor::default());
1433            assert!(
1434                store.heap_bytes() > base,
1435                "a descriptor must increase the accounted heap size"
1436            );
1437        }
1438
1439        #[test]
1440        fn set_and_read_shape_descriptors_envelope_slot() {
1441            let mut map: BTreeMap<NodeId, ShapeDescriptor> = BTreeMap::new();
1442            map.insert(NodeId::new(6, 1), descriptor_with_branches(3));
1443            let mut store = NodeMetadataStore::new();
1444            store.set_shape_descriptors(map);
1445            assert_eq!(store.shape_descriptors().len(), 1);
1446            assert_eq!(
1447                store
1448                    .shape_descriptor(NodeId::new(6, 1))
1449                    .map(|d| d.cf_histogram[CfBucket::Branch.index()]),
1450                Some(3)
1451            );
1452        }
1453    }
1454
1455    // ------------------------------------------------------------------
1456    // U02_NODE_FLAGS: NodeFlags / StoredEntry / co-occurrence semantics
1457    // ------------------------------------------------------------------
1458
1459    mod node_flags_tests {
1460        use super::*;
1461
1462        #[test]
1463        fn node_flags_bit_composition() {
1464            // SYNTHETIC | ADDRESS_TAKEN coexist; setting one doesn't clear the other.
1465            let mut f = NodeFlags::EMPTY;
1466            assert!(f.is_empty());
1467            f.insert(NodeFlags::SYNTHETIC);
1468            assert!(f.contains(NodeFlags::SYNTHETIC));
1469            assert!(!f.contains(NodeFlags::ADDRESS_TAKEN));
1470
1471            f.insert(NodeFlags::ADDRESS_TAKEN);
1472            assert!(
1473                f.contains(NodeFlags::SYNTHETIC),
1474                "ADDRESS_TAKEN insert must not clear SYNTHETIC"
1475            );
1476            assert!(f.contains(NodeFlags::ADDRESS_TAKEN));
1477
1478            // contains(EMPTY) is trivially true.
1479            assert!(f.contains(NodeFlags::EMPTY));
1480
1481            // remove preserves the other bit.
1482            f.remove(NodeFlags::SYNTHETIC);
1483            assert!(!f.contains(NodeFlags::SYNTHETIC));
1484            assert!(f.contains(NodeFlags::ADDRESS_TAKEN));
1485
1486            // BitOr / BitOrAssign work for ergonomic construction.
1487            let combined = NodeFlags::SYNTHETIC | NodeFlags::CALLSITE_PROMISCUOUS;
1488            assert!(combined.contains(NodeFlags::SYNTHETIC));
1489            assert!(combined.contains(NodeFlags::CALLSITE_PROMISCUOUS));
1490            assert!(!combined.contains(NodeFlags::ADDRESS_TAKEN));
1491
1492            let mut acc = NodeFlags::EMPTY;
1493            acc |= NodeFlags::ADDRESS_TAKEN;
1494            acc |= NodeFlags::CALLSITE_PROMISCUOUS;
1495            assert!(acc.contains(NodeFlags::ADDRESS_TAKEN | NodeFlags::CALLSITE_PROMISCUOUS));
1496        }
1497
1498        #[test]
1499        fn stored_entry_flags_only_no_typed_payload() {
1500            // typed = None + flags = ADDRESS_TAKEN ONLY: pure marker entry.
1501            let entry = StoredEntry::with_flags(NodeFlags::ADDRESS_TAKEN);
1502            assert!(entry.typed.is_none());
1503            assert!(entry.flags.contains(NodeFlags::ADDRESS_TAKEN));
1504            assert!(!entry.flags.contains(NodeFlags::SYNTHETIC));
1505            assert!(!entry.is_vacant());
1506
1507            // A truly vacant entry is detectable.
1508            assert!(StoredEntry::default().is_vacant());
1509        }
1510
1511        #[test]
1512        fn co_occurrence_macro_and_address_taken() {
1513            // The design's whole point: TypedMetadata::Macro AND
1514            // NodeFlags::ADDRESS_TAKEN coexist in one entry. Both channels
1515            // return positively.
1516            let mut store = NodeMetadataStore::new();
1517            let node = NodeId::new(42, 1);
1518
1519            store.insert(
1520                node,
1521                MacroNodeMetadata {
1522                    macro_generated: Some(true),
1523                    macro_source: Some("DEFINE_HANDLER".to_string()),
1524                    ..Default::default()
1525                },
1526            );
1527            store.mark_address_taken(node);
1528
1529            // Typed channel: Macro payload preserved.
1530            let typed = store.get_typed(node).expect("typed entry present");
1531            assert!(matches!(typed, TypedMetadata::Macro(_)));
1532            let macro_meta = store.get_macro(node).expect("macro payload present");
1533            assert_eq!(macro_meta.macro_source.as_deref(), Some("DEFINE_HANDLER"));
1534
1535            // Flag channel: ADDRESS_TAKEN set.
1536            assert!(store.is_address_taken(node));
1537            assert!(!store.is_synthetic(node));
1538            assert!(!store.is_callsite_promiscuous(node));
1539
1540            // Adding another flag must NOT disturb the typed payload.
1541            store.mark_synthetic(node);
1542            assert!(store.is_synthetic(node));
1543            assert!(store.is_address_taken(node));
1544            assert!(
1545                store.get_macro(node).is_some(),
1546                "mark_synthetic must not clobber Macro payload"
1547            );
1548        }
1549
1550        #[test]
1551        fn synthetic_via_flag_not_typed() {
1552            // After mark_synthetic, get_typed == None AND is_synthetic == true
1553            // (no typed payload was created — the marker lives in flags alone).
1554            let mut store = NodeMetadataStore::new();
1555            let node = NodeId::new(7, 1);
1556
1557            assert!(!store.is_synthetic(node), "missing entry must report false");
1558
1559            store.mark_synthetic(node);
1560            assert!(store.is_synthetic(node));
1561            assert!(
1562                store.get_typed(node).is_none(),
1563                "mark_synthetic must not populate the typed slot"
1564            );
1565            assert!(store.get_macro(node).is_none());
1566
1567            // Stale generation must NOT see the synthetic flag.
1568            let stale = NodeId::new(7, 2);
1569            assert!(!store.is_synthetic(stale));
1570        }
1571
1572        #[test]
1573        fn mark_address_taken_preserves_typed_payload() {
1574            // Co-occurrence regression test: mark_address_taken on a node with
1575            // existing Macro payload must NOT replace or drop the payload.
1576            let mut store = NodeMetadataStore::new();
1577            let node = NodeId::new(101, 3);
1578            let macro_meta = MacroNodeMetadata {
1579                macro_generated: Some(true),
1580                macro_source: Some("foo_macro".to_string()),
1581                cfg_condition: Some("feature = \"x\"".to_string()),
1582                ..Default::default()
1583            };
1584            store.insert(node, macro_meta.clone());
1585
1586            store.mark_address_taken(node);
1587
1588            assert!(store.is_address_taken(node));
1589            let retrieved = store.get_macro(node).expect("Macro payload preserved");
1590            assert_eq!(retrieved, &macro_meta);
1591        }
1592
1593        #[test]
1594        fn mark_callsite_promiscuous_independent_of_typed() {
1595            // A callsite-promiscuous node may have no typed payload AND may
1596            // have any other flag set independently.
1597            let mut store = NodeMetadataStore::new();
1598            let node = NodeId::new(202, 0);
1599
1600            store.mark_callsite_promiscuous(node);
1601            assert!(store.is_callsite_promiscuous(node));
1602            assert!(!store.is_synthetic(node));
1603            assert!(!store.is_address_taken(node));
1604            assert!(store.get_typed(node).is_none());
1605
1606            // Add SYNTHETIC: must compose, not clobber.
1607            store.mark_synthetic(node);
1608            assert!(store.is_callsite_promiscuous(node));
1609            assert!(store.is_synthetic(node));
1610        }
1611
1612        #[test]
1613        fn get_flags_returns_empty_for_missing_entry() {
1614            let store = NodeMetadataStore::new();
1615            let missing = NodeId::new(999, 0);
1616            assert!(store.get_flags(missing).is_empty());
1617            assert!(!store.is_synthetic(missing));
1618            assert!(!store.is_address_taken(missing));
1619            assert!(!store.is_callsite_promiscuous(missing));
1620        }
1621
1622        #[test]
1623        fn get_macro_roundtrips_typed_macro_storage() {
1624            // get_macro returns Some for a TypedMetadata::Macro entry created
1625            // via either `insert` (convenience) or `insert_typed` (raw).
1626            let mut store = NodeMetadataStore::new();
1627            let node_a = NodeId::new(1, 0);
1628            let node_b = NodeId::new(2, 0);
1629            let payload_a = MacroNodeMetadata {
1630                cfg_condition: Some("a".to_string()),
1631                ..Default::default()
1632            };
1633            let payload_b = MacroNodeMetadata {
1634                cfg_condition: Some("b".to_string()),
1635                ..Default::default()
1636            };
1637
1638            store.insert(node_a, payload_a.clone());
1639            store.insert_typed(node_b, TypedMetadata::Macro(payload_b.clone()));
1640
1641            assert_eq!(store.get_macro(node_a), Some(&payload_a));
1642            assert_eq!(store.get_macro(node_b), Some(&payload_b));
1643        }
1644
1645        #[test]
1646        fn serialize_deserialize_preserves_typed_and_flags() {
1647            // Build a store mixing every shape: Macro+flags, Classpath alone,
1648            // flags-only (single + multi), and a stale-generation key.
1649            let mut store = NodeMetadataStore::new();
1650
1651            store.insert(
1652                NodeId::new(1, 0),
1653                MacroNodeMetadata {
1654                    macro_generated: Some(true),
1655                    ..Default::default()
1656                },
1657            );
1658            store.mark_address_taken(NodeId::new(1, 0));
1659
1660            store.insert_typed(
1661                NodeId::new(2, 0),
1662                TypedMetadata::Classpath(ClasspathNodeMetadata {
1663                    coordinates: None,
1664                    jar_path: "x.jar".to_string(),
1665                    fqn: "com.example.X".to_string(),
1666                    is_direct_dependency: true,
1667                }),
1668            );
1669
1670            store.mark_synthetic(NodeId::new(3, 0));
1671            store.mark_address_taken(NodeId::new(4, 9));
1672            store.mark_callsite_promiscuous(NodeId::new(4, 9));
1673
1674            let bytes = postcard::to_allocvec(&store).expect("serialize");
1675            let decoded: NodeMetadataStore = postcard::from_bytes(&bytes).expect("deserialize");
1676
1677            assert_eq!(store, decoded);
1678
1679            // Spot-check that both channels round-tripped, not just equality.
1680            assert!(decoded.get_macro(NodeId::new(1, 0)).is_some());
1681            assert!(decoded.is_address_taken(NodeId::new(1, 0)));
1682            assert!(matches!(
1683                decoded.get_typed(NodeId::new(2, 0)),
1684                Some(TypedMetadata::Classpath(_))
1685            ));
1686            assert!(decoded.is_synthetic(NodeId::new(3, 0)));
1687            assert!(decoded.is_address_taken(NodeId::new(4, 9)));
1688            assert!(decoded.is_callsite_promiscuous(NodeId::new(4, 9)));
1689        }
1690
1691        #[test]
1692        fn json_serialize_deserialize_preserves_typed_and_flags() {
1693            // serde_json path used by MCP export. Same shape as the postcard
1694            // round-trip — the Serialize/Deserialize impls are wire-format
1695            // agnostic.
1696            let mut store = NodeMetadataStore::new();
1697            store.insert(
1698                NodeId::new(5, 5),
1699                MacroNodeMetadata {
1700                    macro_generated: Some(true),
1701                    ..Default::default()
1702                },
1703            );
1704            store.mark_address_taken(NodeId::new(5, 5));
1705            store.mark_synthetic(NodeId::new(9, 0));
1706
1707            let json = serde_json::to_string(&store).expect("json serialize");
1708            let decoded: NodeMetadataStore = serde_json::from_str(&json).expect("json deserialize");
1709            assert_eq!(store, decoded);
1710        }
1711
1712        #[test]
1713        fn insert_entry_bulk_remap_path() {
1714            // The classpath emitter rebuilds the store from `iter_entries()`
1715            // + `insert_entry()` during id remapping; that path must preserve
1716            // both channels.
1717            let mut original = NodeMetadataStore::new();
1718            original.insert_typed(
1719                NodeId::new(10, 0),
1720                TypedMetadata::Classpath(ClasspathNodeMetadata {
1721                    coordinates: Some("g:a:1".to_string()),
1722                    jar_path: "j.jar".to_string(),
1723                    fqn: "F".to_string(),
1724                    is_direct_dependency: true,
1725                }),
1726            );
1727            original.mark_address_taken(NodeId::new(10, 0));
1728            original.mark_synthetic(NodeId::new(11, 0));
1729
1730            // Simulate the emitter's remap: copy via iter_entries.
1731            let mut remapped = NodeMetadataStore::new();
1732            for (key, entry) in original.iter_entries() {
1733                let nid = NodeId::new(key.0, key.1);
1734                remapped.insert_entry(nid, entry.clone());
1735            }
1736
1737            assert_eq!(original, remapped);
1738            assert!(remapped.is_address_taken(NodeId::new(10, 0)));
1739            assert!(matches!(
1740                remapped.get_typed(NodeId::new(10, 0)),
1741                Some(TypedMetadata::Classpath(_))
1742            ));
1743            assert!(remapped.is_synthetic(NodeId::new(11, 0)));
1744        }
1745    }
1746}