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::HashMap;
26
27use serde::{Deserialize, Serialize};
28
29use super::super::node::id::NodeId;
30
31/// Optional metadata for nodes that participate in macro boundary analysis.
32///
33/// Stored separately from [`NodeEntry`] to avoid bloating the arena for the
34/// majority of nodes that don't need macro metadata.
35///
36/// Each field is `Option` (or `Vec` with empty default) so only relevant
37/// metadata consumes space in the serialized representation.
38#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
39pub struct MacroNodeMetadata {
40    /// Whether this symbol was generated by macro expansion.
41    pub macro_generated: Option<bool>,
42
43    /// Qualified name of the macro that generated this symbol.
44    pub macro_source: Option<String>,
45
46    /// The cfg predicate string (e.g., `"test"`, `"feature = \"serde\""`)
47    pub cfg_condition: Option<String>,
48
49    /// Whether this cfg is active (`None` = unknown, requires external config).
50    pub cfg_active: Option<bool>,
51
52    /// Proc-macro kind for proc-macro function nodes.
53    pub proc_macro_kind: Option<ProcMacroFunctionKind>,
54
55    /// Whether expansion data came from cache vs live `cargo expand`.
56    pub expansion_cached: Option<bool>,
57
58    /// Unresolved attribute paths that could not be positively identified
59    /// as proc-macro attributes. Stored for potential future resolution.
60    pub unresolved_attributes: Vec<String>,
61}
62
63/// Classification of proc-macro function types.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum ProcMacroFunctionKind {
67    /// `#[proc_macro_derive(Name)]` — generates impls for structs/enums.
68    Derive,
69    /// `#[proc_macro_attribute]` — transforms annotated items.
70    Attribute,
71    /// `#[proc_macro]` — function-like `my_macro!(...)` invocation.
72    FunctionLike,
73}
74
75/// Metadata for nodes originating from JVM classpath bytecode.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct ClasspathNodeMetadata {
78    /// Maven coordinates (e.g., `"com.google.guava:guava:33.0.0"`).
79    pub coordinates: Option<String>,
80    /// JAR file path this node was extracted from.
81    pub jar_path: String,
82    /// Fully qualified name in JVM format (e.g., `"java.util.HashMap"`).
83    pub fqn: String,
84    /// Whether this is a direct or transitive dependency.
85    pub is_direct_dependency: bool,
86}
87
88/// Mutually-exclusive payload-bearing metadata variants.
89///
90/// A node has at most one `TypedMetadata` at a time (Macro is Rust-only,
91/// Classpath is JVM-only — they cannot co-occur by language). Independent
92/// boolean attributes are carried by [`NodeFlags`] instead.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum TypedMetadata {
95    /// Rust macro-related metadata.
96    Macro(MacroNodeMetadata),
97    /// JVM classpath provenance metadata.
98    Classpath(ClasspathNodeMetadata),
99}
100
101/// Payload-less marker flags, independently composable.
102///
103/// Each flag is a single bit in a `u8` bitset. Flags compose freely with each
104/// other AND with the [`TypedMetadata`] channel — e.g. a node may carry
105/// `TypedMetadata::Macro(_)` AND `NodeFlags::SYNTHETIC | NodeFlags::ADDRESS_TAKEN`
106/// simultaneously.
107#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
108#[repr(transparent)]
109pub struct NodeFlags(u8);
110
111impl NodeFlags {
112    /// Synthetic placeholder node marker (C_SUPPRESS).
113    ///
114    /// Identifies internal-use-only nodes that language plugins emit as
115    /// shadows / scaffolding for binding-plane analysis (e.g. the Go
116    /// plugin's `<field:operand.field>` field-access placeholders and the
117    /// `name@<offset>` per-binding-site Variable nodes from the local-scope
118    /// resolver). These nodes must be suppressed from user-facing search
119    /// surfaces but remain reachable to internal callers via the explicit
120    /// `include_synthetic` opt-in path.
121    pub const SYNTHETIC: NodeFlags = NodeFlags(1 << 0);
122
123    /// Function whose address has been taken at some site in the workspace
124    /// (e.g. `&foo`, passed as an argument, stored in a function-pointer
125    /// field). Populated by the C plugin's address-taken classifier; consumed
126    /// by the C indirect-call resolver to scope type-signature matches.
127    pub const ADDRESS_TAKEN: NodeFlags = NodeFlags(1 << 1);
128
129    /// Call-site for which the indirect-call resolver exceeded the
130    /// per-callsite cardinality cap. The original `Calls`-stub edge is
131    /// preserved (no per-callee edges emitted) and the caller is flagged
132    /// so planners and consumers can surface the over-cap fan-out.
133    pub const CALLSITE_PROMISCUOUS: NodeFlags = NodeFlags(1 << 2);
134
135    /// Empty bitset.
136    pub const EMPTY: NodeFlags = NodeFlags(0);
137
138    /// Returns `true` iff every bit in `other` is set in `self`.
139    #[must_use]
140    pub const fn contains(self, other: NodeFlags) -> bool {
141        (self.0 & other.0) == other.0
142    }
143
144    /// Set every bit in `other`.
145    pub fn insert(&mut self, other: NodeFlags) {
146        self.0 |= other.0;
147    }
148
149    /// Clear every bit in `other`.
150    pub fn remove(&mut self, other: NodeFlags) {
151        self.0 &= !other.0;
152    }
153
154    /// Returns `true` iff no bits are set.
155    #[must_use]
156    pub const fn is_empty(self) -> bool {
157        self.0 == 0
158    }
159
160    /// Raw byte value (used by the wire format).
161    #[must_use]
162    pub const fn bits(self) -> u8 {
163        self.0
164    }
165
166    /// Construct from a raw byte value (used by the wire format).
167    #[must_use]
168    pub const fn from_bits(bits: u8) -> NodeFlags {
169        NodeFlags(bits)
170    }
171}
172
173impl std::ops::BitOr for NodeFlags {
174    type Output = NodeFlags;
175    fn bitor(self, rhs: NodeFlags) -> NodeFlags {
176        NodeFlags(self.0 | rhs.0)
177    }
178}
179
180impl std::ops::BitOrAssign for NodeFlags {
181    fn bitor_assign(&mut self, rhs: NodeFlags) {
182        self.0 |= rhs.0;
183    }
184}
185
186/// Per-`NodeId` metadata entry: one typed payload slot + one flag bitset.
187///
188/// The two channels are independent — `mark_*` methods on
189/// [`NodeMetadataStore`] update `flags` without disturbing `typed`, and
190/// `insert_typed` updates `typed` without disturbing `flags`. This is what
191/// enables co-occurrence of marker bits with typed payloads (e.g. a
192/// macro-generated function whose address has been taken).
193#[derive(Debug, Clone, Default, PartialEq, Eq)]
194pub struct StoredEntry {
195    /// Mutually-exclusive payload-bearing metadata. `None` when the node
196    /// only carries marker flags (no macro / classpath provenance).
197    pub typed: Option<TypedMetadata>,
198    /// Independently-composable marker flags.
199    pub flags: NodeFlags,
200}
201
202impl StoredEntry {
203    /// Construct from a typed payload with empty flags.
204    #[must_use]
205    pub fn with_typed(typed: TypedMetadata) -> StoredEntry {
206        StoredEntry {
207            typed: Some(typed),
208            flags: NodeFlags::EMPTY,
209        }
210    }
211
212    /// Construct from flag bits with no typed payload.
213    #[must_use]
214    pub fn with_flags(flags: NodeFlags) -> StoredEntry {
215        StoredEntry { typed: None, flags }
216    }
217
218    /// Returns `true` iff this entry has neither typed payload nor any flags set.
219    #[must_use]
220    pub fn is_vacant(&self) -> bool {
221        self.typed.is_none() && self.flags.is_empty()
222    }
223}
224
225/// Sparse metadata store keyed by full `NodeId` (index + generation).
226///
227/// Uses `(u32, u64)` tuple key to prevent stale metadata when the
228/// generational arena reuses a slot index with a new generation.
229/// A lookup with `NodeId { index: 5, generation: 3 }` will NOT match metadata
230/// stored for `NodeId { index: 5, generation: 2 }`.
231///
232/// # Memory characteristics
233///
234/// For a typical large codebase (100K nodes), only ~5-10% of nodes have
235/// metadata. A store with 10K entries at ~200 bytes each = ~2MB, which is
236/// acceptable given snapshots are already 10-50MB.
237///
238/// # Serialization
239///
240/// The in-memory representation uses `HashMap` for O(1) lookups. For postcard
241/// serialization (which doesn't support tuple keys natively), we serialize as
242/// a `Vec` of [`NodeMetadataEntryV11`] structs with explicit `index`,
243/// `generation`, `kind`, payload-slots, and `flags`, then reconstruct the
244/// `HashMap` on deserialization.
245#[derive(Debug, Clone, Default)]
246pub struct NodeMetadataStore {
247    /// Metadata entries keyed by `(NodeId::index(), NodeId::generation())`.
248    entries: HashMap<(u32, u64), StoredEntry>,
249}
250
251/// Discriminant values for the on-wire `kind` byte.
252const TYPED_KIND_NONE: u8 = 0;
253const TYPED_KIND_MACRO: u8 = 1;
254const TYPED_KIND_CLASSPATH: u8 = 2;
255
256/// V11 wire-format entry for a single metadata record.
257///
258/// Adds `flags: u8` after the V7 layout. The `kind` byte now describes the
259/// typed payload only (Macro / Classpath / None — synthetic moved to `flags`).
260#[derive(Debug, Clone, Serialize, Deserialize)]
261struct NodeMetadataEntryV11 {
262    index: u32,
263    generation: u64,
264    /// Typed-payload discriminant: 0 = None, 1 = Macro, 2 = Classpath.
265    kind: u8,
266    /// Macro payload (present when `kind == TYPED_KIND_MACRO`).
267    macro_data: Option<MacroNodeMetadata>,
268    /// Classpath payload (present when `kind == TYPED_KIND_CLASSPATH`).
269    classpath_data: Option<ClasspathNodeMetadata>,
270    /// Marker-flag bitset (raw [`NodeFlags`] byte).
271    flags: u8,
272}
273
274// Legacy V7 / V10 wire-format entries (`NodeMetadataEntryV7Legacy` +
275// `LEGACY_V7_KIND_*` constants) moved into
276// `sqry-core/src/graph/unified/persistence/legacy_v10.rs` in U03 codex
277// iter-1, where they are owned by the versioned V10 wire-type module
278// alongside the `EdgeKindV10` mirror. The codex review flagged the
279// duplicate definition here as dead code; the canonical home is now the
280// persistence/legacy_v10 module, which is where the V10 → V11 upconvert
281// translates them into the live `StoredEntry { typed, flags }` shape.
282
283impl Serialize for NodeMetadataStore {
284    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
285        let entries: Vec<NodeMetadataEntryV11> = self
286            .entries
287            .iter()
288            .map(|(&(index, generation), stored)| {
289                let (kind, macro_data, classpath_data) = match &stored.typed {
290                    None => (TYPED_KIND_NONE, None, None),
291                    Some(TypedMetadata::Macro(m)) => (TYPED_KIND_MACRO, Some(m.clone()), None),
292                    Some(TypedMetadata::Classpath(c)) => {
293                        (TYPED_KIND_CLASSPATH, None, Some(c.clone()))
294                    }
295                };
296                NodeMetadataEntryV11 {
297                    index,
298                    generation,
299                    kind,
300                    macro_data,
301                    classpath_data,
302                    flags: stored.flags.bits(),
303                }
304            })
305            .collect();
306        entries.serialize(serializer)
307    }
308}
309
310impl<'de> Deserialize<'de> for NodeMetadataStore {
311    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
312        let entries: Vec<NodeMetadataEntryV11> = Vec::deserialize(deserializer)?;
313        let mut map = HashMap::with_capacity(entries.len());
314        for e in entries {
315            let typed = match e.kind {
316                TYPED_KIND_NONE => None,
317                TYPED_KIND_MACRO => Some(TypedMetadata::Macro(e.macro_data.unwrap_or_default())),
318                TYPED_KIND_CLASSPATH => {
319                    let data = e.classpath_data.ok_or_else(|| {
320                        serde::de::Error::custom(
321                            "missing classpath_data for Classpath typed metadata entry",
322                        )
323                    })?;
324                    Some(TypedMetadata::Classpath(data))
325                }
326                other => {
327                    return Err(serde::de::Error::custom(format!(
328                        "unknown typed-metadata kind discriminant {other}"
329                    )));
330                }
331            };
332            let stored = StoredEntry {
333                typed,
334                flags: NodeFlags::from_bits(e.flags),
335            };
336            map.insert((e.index, e.generation), stored);
337        }
338        Ok(Self { entries: map })
339    }
340}
341
342impl NodeMetadataStore {
343    /// Create a new empty metadata store.
344    #[must_use]
345    pub fn new() -> Self {
346        Self::default()
347    }
348
349    // ---------------------------------------------------------------
350    // Typed-payload accessors
351    // ---------------------------------------------------------------
352
353    /// Borrowed access to the typed payload for a node, if any.
354    ///
355    /// Returns `None` when the node has only marker flags or no entry at
356    /// all. Stale (generation-mismatched) `NodeId`s also return `None`.
357    #[must_use]
358    pub fn get_typed(&self, node_id: NodeId) -> Option<&TypedMetadata> {
359        self.entries
360            .get(&(node_id.index(), node_id.generation()))?
361            .typed
362            .as_ref()
363    }
364
365    /// Mutable access to the typed payload for a node, if any.
366    pub fn get_typed_mut(&mut self, node_id: NodeId) -> Option<&mut TypedMetadata> {
367        self.entries
368            .get_mut(&(node_id.index(), node_id.generation()))?
369            .typed
370            .as_mut()
371    }
372
373    /// Convenience accessor for the [`TypedMetadata::Macro`] variant only.
374    ///
375    /// Replaces the legacy `get` accessor. Returns `None` for classpath
376    /// entries, marker-only entries, missing entries, and stale generations.
377    #[must_use]
378    pub fn get_macro(&self, node_id: NodeId) -> Option<&MacroNodeMetadata> {
379        match self.get_typed(node_id)? {
380            TypedMetadata::Macro(m) => Some(m),
381            TypedMetadata::Classpath(_) => None,
382        }
383    }
384
385    /// Mutable accessor for the [`TypedMetadata::Macro`] variant only.
386    pub fn get_macro_mut(&mut self, node_id: NodeId) -> Option<&mut MacroNodeMetadata> {
387        match self.get_typed_mut(node_id)? {
388            TypedMetadata::Macro(m) => Some(m),
389            TypedMetadata::Classpath(_) => None,
390        }
391    }
392
393    // ---------------------------------------------------------------
394    // Marker-flag accessors
395    // ---------------------------------------------------------------
396
397    /// Returns the marker-flag bitset (empty bitset when no entry exists).
398    ///
399    /// Cheap by-value `Copy` return — no borrow contention with
400    /// [`Self::get_typed`].
401    #[must_use]
402    pub fn get_flags(&self, node_id: NodeId) -> NodeFlags {
403        self.entries
404            .get(&(node_id.index(), node_id.generation()))
405            .map(|e| e.flags)
406            .unwrap_or(NodeFlags::EMPTY)
407    }
408
409    /// Returns `true` iff the node has [`NodeFlags::SYNTHETIC`] set.
410    #[must_use]
411    pub fn is_synthetic(&self, node_id: NodeId) -> bool {
412        self.get_flags(node_id).contains(NodeFlags::SYNTHETIC)
413    }
414
415    /// Returns `true` iff the node has [`NodeFlags::ADDRESS_TAKEN`] set.
416    #[must_use]
417    pub fn is_address_taken(&self, node_id: NodeId) -> bool {
418        self.get_flags(node_id).contains(NodeFlags::ADDRESS_TAKEN)
419    }
420
421    /// Returns `true` iff the node has [`NodeFlags::CALLSITE_PROMISCUOUS`] set.
422    #[must_use]
423    pub fn is_callsite_promiscuous(&self, node_id: NodeId) -> bool {
424        self.get_flags(node_id)
425            .contains(NodeFlags::CALLSITE_PROMISCUOUS)
426    }
427
428    /// Set [`NodeFlags::SYNTHETIC`] on this node.
429    ///
430    /// Does NOT disturb any existing typed payload — a macro-generated node
431    /// that is also marked synthetic retains both channels.
432    pub fn mark_synthetic(&mut self, node_id: NodeId) {
433        self.set_flag(node_id, NodeFlags::SYNTHETIC);
434    }
435
436    /// Set [`NodeFlags::ADDRESS_TAKEN`] on this node.
437    ///
438    /// Does NOT disturb any existing typed payload — preserves Macro /
439    /// Classpath provenance for nodes that happen to be address-taken (e.g.
440    /// a `DEFINE_HANDLER(my_irq)` C function that is also `&my_irq`'d).
441    pub fn mark_address_taken(&mut self, node_id: NodeId) {
442        self.set_flag(node_id, NodeFlags::ADDRESS_TAKEN);
443    }
444
445    /// Set [`NodeFlags::CALLSITE_PROMISCUOUS`] on this node.
446    ///
447    /// Does NOT disturb any existing typed payload.
448    pub fn mark_callsite_promiscuous(&mut self, node_id: NodeId) {
449        self.set_flag(node_id, NodeFlags::CALLSITE_PROMISCUOUS);
450    }
451
452    fn set_flag(&mut self, node_id: NodeId, flag: NodeFlags) {
453        self.entries
454            .entry((node_id.index(), node_id.generation()))
455            .or_default()
456            .flags
457            .insert(flag);
458    }
459
460    // ---------------------------------------------------------------
461    // Insertion / removal
462    // ---------------------------------------------------------------
463
464    /// Insert macro metadata for a node, replacing any existing typed
465    /// payload at this `NodeId`. Marker flags on the existing entry are
466    /// preserved.
467    pub fn insert(&mut self, node_id: NodeId, metadata: MacroNodeMetadata) {
468        self.insert_typed(node_id, TypedMetadata::Macro(metadata));
469    }
470
471    /// Insert a typed payload for a node, replacing any existing typed
472    /// payload. Marker flags on the existing entry are preserved.
473    pub fn insert_typed(&mut self, node_id: NodeId, typed: TypedMetadata) {
474        let slot = self
475            .entries
476            .entry((node_id.index(), node_id.generation()))
477            .or_default();
478        slot.typed = Some(typed);
479    }
480
481    /// Insert a fully-formed [`StoredEntry`] for a node, replacing any
482    /// existing entry. Used by bulk-remap paths (e.g. classpath emitter
483    /// id remapping) and the snapshot upconvert path.
484    pub fn insert_entry(&mut self, node_id: NodeId, entry: StoredEntry) {
485        self.entries
486            .insert((node_id.index(), node_id.generation()), entry);
487    }
488
489    /// Get or insert a default macro-metadata entry for a node.
490    ///
491    /// If the entry doesn't exist, it's created with an empty
492    /// `MacroNodeMetadata` typed payload and empty flags.
493    ///
494    /// # Panics
495    ///
496    /// Panics if the existing entry has a non-Macro typed payload
497    /// (i.e. [`TypedMetadata::Classpath`]). Callers must not mix typed
498    /// payloads at the same key.
499    pub fn get_or_insert_default(&mut self, node_id: NodeId) -> &mut MacroNodeMetadata {
500        let slot = self
501            .entries
502            .entry((node_id.index(), node_id.generation()))
503            .or_insert_with(|| {
504                StoredEntry::with_typed(TypedMetadata::Macro(MacroNodeMetadata::default()))
505            });
506        if slot.typed.is_none() {
507            slot.typed = Some(TypedMetadata::Macro(MacroNodeMetadata::default()));
508        }
509        match slot.typed.as_mut() {
510            Some(TypedMetadata::Macro(m)) => m,
511            Some(TypedMetadata::Classpath(_)) => {
512                panic!("get_or_insert_default called on a Classpath typed metadata entry")
513            }
514            None => unreachable!("just populated above"),
515        }
516    }
517
518    /// Remove the entry for a node and return its macro payload, if any.
519    ///
520    /// Returns `None` if no entry existed, or if the entry's typed payload
521    /// was not [`TypedMetadata::Macro`]. The entry (including any flags) is
522    /// removed in all cases when an entry was present.
523    pub fn remove(&mut self, node_id: NodeId) -> Option<MacroNodeMetadata> {
524        match self
525            .entries
526            .remove(&(node_id.index(), node_id.generation()))?
527            .typed
528        {
529            Some(TypedMetadata::Macro(m)) => Some(m),
530            Some(TypedMetadata::Classpath(_)) | None => None,
531        }
532    }
533
534    /// Remove the entry for a node and return the full [`StoredEntry`].
535    pub fn remove_entry(&mut self, node_id: NodeId) -> Option<StoredEntry> {
536        self.entries
537            .remove(&(node_id.index(), node_id.generation()))
538    }
539
540    // ---------------------------------------------------------------
541    // Iteration / bookkeeping
542    // ---------------------------------------------------------------
543
544    /// Returns the number of nodes with metadata.
545    #[must_use]
546    pub fn len(&self) -> usize {
547        self.entries.len()
548    }
549
550    /// Returns true if no nodes have metadata.
551    #[must_use]
552    pub fn is_empty(&self) -> bool {
553        self.entries.is_empty()
554    }
555
556    /// Iterate over entries whose typed payload is `Macro`, yielding the
557    /// `MacroNodeMetadata`. Entries that are flag-only, classpath, or
558    /// payload-less are skipped.
559    pub fn iter(&self) -> impl Iterator<Item = ((u32, u64), &MacroNodeMetadata)> {
560        self.entries.iter().filter_map(|(&k, v)| match &v.typed {
561            Some(TypedMetadata::Macro(m)) => Some((k, m)),
562            Some(TypedMetadata::Classpath(_)) | None => None,
563        })
564    }
565
566    /// Iterate over every stored entry as `(key, &StoredEntry)`.
567    ///
568    /// Replaces the legacy `iter_all` accessor that returned `&NodeMetadata`.
569    pub fn iter_entries(&self) -> impl Iterator<Item = ((u32, u64), &StoredEntry)> {
570        self.entries.iter().map(|(&k, v)| (k, v))
571    }
572
573    /// Merge another metadata store into this one.
574    ///
575    /// Entries from `other` overwrite existing entries with the same key.
576    pub fn merge(&mut self, other: &NodeMetadataStore) {
577        for (&key, value) in &other.entries {
578            self.entries.insert(key, value.clone());
579        }
580    }
581
582    /// Retain only entries whose `(index, generation)` key satisfies `keep`.
583    ///
584    /// Used by the Gate 0b [`NodeIdBearing`] impl
585    /// (`sqry-core/src/graph/unified/rebuild/coverage.rs`) to drop
586    /// metadata for tombstoned NodeIds during
587    /// `RebuildGraph::finalize()`. Exposed at `pub(crate)` scope because
588    /// only the rebuild pipeline needs predicate-based filtering;
589    /// downstream callers use the targeted [`Self::remove`] /
590    /// [`Self::remove_entry`] entry points.
591    ///
592    /// `#[allow(dead_code)]` is present because Gate 0b delivers only
593    /// the scaffolding — the call sites in `RebuildGraph::finalize()`
594    /// (Gate 0c) and the residue check (Gate 0d) land in follow-up
595    /// commits. Unit coverage in
596    /// `sqry-core/src/graph/unified/rebuild/coverage.rs::tests` already
597    /// exercises this helper through the [`NodeIdBearing::retain_nodes`]
598    /// impl.
599    ///
600    /// [`NodeIdBearing`]: crate::graph::unified::rebuild::coverage::NodeIdBearing
601    /// [`NodeIdBearing::retain_nodes`]: crate::graph::unified::rebuild::coverage::NodeIdBearing::retain_nodes
602    #[allow(dead_code)]
603    pub(crate) fn retain_entries<F>(&mut self, mut keep: F)
604    where
605        F: FnMut(u32, u64) -> bool,
606    {
607        self.entries
608            .retain(|&(index, generation), _entry| keep(index, generation));
609    }
610
611    /// Test-only: clear the Phase A marker bits
612    /// ([`NodeFlags::ADDRESS_TAKEN`] and [`NodeFlags::CALLSITE_PROMISCUOUS`])
613    /// from every stored entry, leaving every other flag and the typed
614    /// payload untouched.
615    ///
616    /// Used exclusively by `sqry-core/tests/snapshot_size_phase_a.rs`
617    /// (U19) to materialize a "Phase-A-free" baseline snapshot for the
618    /// +10% snapshot-size gate.
619    ///
620    /// Gated behind `cfg(any(test, feature = "test-support"))` so the
621    /// helper is invisible to production builds and never accidentally
622    /// invoked from non-test surfaces.
623    #[cfg(any(test, feature = "test-support"))]
624    pub fn clear_phase_a_flags_for_test(&mut self) {
625        let mask = NodeFlags::ADDRESS_TAKEN | NodeFlags::CALLSITE_PROMISCUOUS;
626        for slot in self.entries.values_mut() {
627            slot.flags.remove(mask);
628        }
629    }
630}
631
632impl PartialEq for NodeMetadataStore {
633    fn eq(&self, other: &Self) -> bool {
634        self.entries == other.entries
635    }
636}
637
638impl Eq for NodeMetadataStore {}
639
640impl crate::graph::unified::memory::GraphMemorySize for NodeMetadataStore {
641    fn heap_bytes(&self) -> usize {
642        use crate::graph::unified::memory::HASHMAP_ENTRY_OVERHEAD;
643
644        let base = self.entries.capacity()
645            * (std::mem::size_of::<(u32, u64)>()
646                + std::mem::size_of::<StoredEntry>()
647                + HASHMAP_ENTRY_OVERHEAD);
648        // Account for heap Strings inside each typed payload. Marker-flag
649        // entries are payload-less — only the `flags` byte itself, which is
650        // counted via mem::size_of::<StoredEntry>() in `base`.
651        let inner: usize = self
652            .entries
653            .values()
654            .map(|entry| match &entry.typed {
655                None => 0,
656                Some(TypedMetadata::Macro(m)) => {
657                    m.macro_source.as_ref().map_or(0, String::capacity)
658                        + m.cfg_condition.as_ref().map_or(0, String::capacity)
659                        + m.unresolved_attributes
660                            .iter()
661                            .map(String::capacity)
662                            .sum::<usize>()
663                        + m.unresolved_attributes.capacity() * std::mem::size_of::<String>()
664                }
665                Some(TypedMetadata::Classpath(c)) => {
666                    c.coordinates.as_ref().map_or(0, String::capacity)
667                        + c.jar_path.capacity()
668                        + c.fqn.capacity()
669                }
670            })
671            .sum();
672        base + inner
673    }
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679
680    #[test]
681    fn test_metadata_store_basic_operations() {
682        let mut store = NodeMetadataStore::new();
683        assert!(store.is_empty());
684        assert_eq!(store.len(), 0);
685
686        let node = NodeId::new(5, 1);
687        let metadata = MacroNodeMetadata {
688            macro_generated: Some(true),
689            macro_source: Some("derive_Debug".to_string()),
690            ..Default::default()
691        };
692
693        store.insert(node, metadata.clone());
694        assert_eq!(store.len(), 1);
695        assert!(!store.is_empty());
696
697        let retrieved = store.get_macro(node).unwrap();
698        assert_eq!(retrieved.macro_generated, Some(true));
699        assert_eq!(retrieved.macro_source.as_deref(), Some("derive_Debug"));
700    }
701
702    #[test]
703    fn test_metadata_full_nodeid_key() {
704        let mut store = NodeMetadataStore::new();
705
706        let node_gen1 = NodeId::new(5, 1);
707        let node_gen2 = NodeId::new(5, 2);
708
709        store.insert(
710            node_gen1,
711            MacroNodeMetadata {
712                macro_generated: Some(true),
713                ..Default::default()
714            },
715        );
716
717        // Same index, different generation → should NOT match
718        assert!(store.get_macro(node_gen2).is_none());
719
720        // Same index, same generation → should match
721        assert!(store.get_macro(node_gen1).is_some());
722    }
723
724    #[test]
725    fn test_metadata_slot_reuse_no_stale_data() {
726        let mut store = NodeMetadataStore::new();
727
728        // Simulate: node at index 5 gen 1 has metadata
729        let old_node = NodeId::new(5, 1);
730        store.insert(
731            old_node,
732            MacroNodeMetadata {
733                cfg_condition: Some("test".to_string()),
734                ..Default::default()
735            },
736        );
737
738        // Simulate: slot 5 is reused with generation 2 (new node)
739        let new_node = NodeId::new(5, 2);
740
741        // New node should NOT see old metadata
742        assert!(store.get_macro(new_node).is_none());
743
744        // Old node still accessible
745        assert_eq!(
746            store.get_macro(old_node).unwrap().cfg_condition.as_deref(),
747            Some("test")
748        );
749    }
750
751    #[test]
752    fn test_metadata_store_postcard_roundtrip() {
753        let mut store = NodeMetadataStore::new();
754
755        store.insert(
756            NodeId::new(1, 0),
757            MacroNodeMetadata {
758                macro_generated: Some(true),
759                macro_source: Some("derive_Debug".to_string()),
760                cfg_condition: Some("test".to_string()),
761                cfg_active: Some(true),
762                proc_macro_kind: Some(ProcMacroFunctionKind::Derive),
763                expansion_cached: Some(false),
764                unresolved_attributes: vec!["my_attr".to_string()],
765            },
766        );
767
768        store.insert(
769            NodeId::new(42, 3),
770            MacroNodeMetadata {
771                cfg_condition: Some("feature = \"serde\"".to_string()),
772                ..Default::default()
773            },
774        );
775
776        let bytes = postcard::to_allocvec(&store).expect("serialize");
777        let deserialized: NodeMetadataStore = postcard::from_bytes(&bytes).expect("deserialize");
778
779        assert_eq!(store, deserialized);
780    }
781
782    #[test]
783    fn test_empty_metadata_store_zero_overhead() {
784        let store = NodeMetadataStore::new();
785        let bytes = postcard::to_allocvec(&store).expect("serialize");
786
787        // Empty HashMap serializes to a single varint length of 0
788        assert!(
789            bytes.len() <= 2,
790            "Empty store should serialize to minimal bytes, got {} bytes",
791            bytes.len()
792        );
793    }
794
795    #[test]
796    fn test_metadata_store_merge() {
797        let mut store1 = NodeMetadataStore::new();
798        let mut store2 = NodeMetadataStore::new();
799
800        store1.insert(
801            NodeId::new(1, 0),
802            MacroNodeMetadata {
803                macro_generated: Some(true),
804                ..Default::default()
805            },
806        );
807
808        store2.insert(
809            NodeId::new(2, 0),
810            MacroNodeMetadata {
811                cfg_condition: Some("test".to_string()),
812                ..Default::default()
813            },
814        );
815
816        store1.merge(&store2);
817        assert_eq!(store1.len(), 2);
818        assert!(store1.get_macro(NodeId::new(1, 0)).is_some());
819        assert!(store1.get_macro(NodeId::new(2, 0)).is_some());
820    }
821
822    #[test]
823    fn test_proc_macro_function_kind_serde() {
824        let kinds = [
825            ProcMacroFunctionKind::Derive,
826            ProcMacroFunctionKind::Attribute,
827            ProcMacroFunctionKind::FunctionLike,
828        ];
829
830        for kind in kinds {
831            let bytes = postcard::to_allocvec(&kind).expect("serialize");
832            let deserialized: ProcMacroFunctionKind =
833                postcard::from_bytes(&bytes).expect("deserialize");
834            assert_eq!(kind, deserialized);
835        }
836    }
837
838    #[test]
839    fn test_metadata_get_or_insert_default() {
840        let mut store = NodeMetadataStore::new();
841        let node = NodeId::new(10, 0);
842
843        // First access creates default
844        let meta = store.get_or_insert_default(node);
845        meta.cfg_condition = Some("test".to_string());
846
847        // Second access returns existing
848        let meta = store.get_macro(node).unwrap();
849        assert_eq!(meta.cfg_condition.as_deref(), Some("test"));
850    }
851
852    #[test]
853    fn test_metadata_remove() {
854        let mut store = NodeMetadataStore::new();
855        let node = NodeId::new(1, 0);
856
857        store.insert(
858            node,
859            MacroNodeMetadata {
860                macro_generated: Some(true),
861                ..Default::default()
862            },
863        );
864
865        assert!(store.get_macro(node).is_some());
866        let removed = store.remove(node);
867        assert!(removed.is_some());
868        assert!(store.get_macro(node).is_none());
869        assert!(store.is_empty());
870    }
871
872    #[test]
873    fn test_metadata_store_large_scale() {
874        let mut store = NodeMetadataStore::new();
875
876        // Insert 10K entries (simulating ~10% of a 100K-node codebase)
877        for i in 0..10_000u32 {
878            store.insert(
879                NodeId::new(i, 0),
880                MacroNodeMetadata {
881                    cfg_condition: Some(format!("feature_{i}")),
882                    ..Default::default()
883                },
884            );
885        }
886
887        assert_eq!(store.len(), 10_000);
888
889        // Verify O(1) lookups
890        assert!(store.get_macro(NodeId::new(0, 0)).is_some());
891        assert!(store.get_macro(NodeId::new(5_000, 0)).is_some());
892        assert!(store.get_macro(NodeId::new(9_999, 0)).is_some());
893        assert!(store.get_macro(NodeId::new(10_000, 0)).is_none());
894
895        // Verify round-trip
896        let bytes = postcard::to_allocvec(&store).expect("serialize");
897        let deserialized: NodeMetadataStore = postcard::from_bytes(&bytes).expect("deserialize");
898        assert_eq!(store, deserialized);
899    }
900
901    #[test]
902    fn test_classpath_metadata_insert_and_get() {
903        let mut store = NodeMetadataStore::new();
904        let node = NodeId::new(100, 0);
905
906        let cp_meta = ClasspathNodeMetadata {
907            coordinates: Some("com.google.guava:guava:33.0.0".to_string()),
908            jar_path: "/home/user/.m2/repository/guava-33.0.0.jar".to_string(),
909            fqn: "com.google.common.collect.ImmutableList".to_string(),
910            is_direct_dependency: true,
911        };
912
913        store.insert_typed(node, TypedMetadata::Classpath(cp_meta.clone()));
914        assert_eq!(store.len(), 1);
915
916        // get_macro should return None (only returns Macro variant)
917        assert!(store.get_macro(node).is_none());
918
919        // get_typed should return the classpath metadata
920        let retrieved = store.get_typed(node).unwrap();
921        match retrieved {
922            TypedMetadata::Classpath(cp) => {
923                assert_eq!(cp.fqn, "com.google.common.collect.ImmutableList");
924                assert_eq!(
925                    cp.coordinates.as_deref(),
926                    Some("com.google.guava:guava:33.0.0")
927                );
928                assert!(cp.is_direct_dependency);
929            }
930            TypedMetadata::Macro(_) => panic!("expected Classpath variant"),
931        }
932    }
933
934    #[test]
935    fn test_classpath_metadata_postcard_roundtrip() {
936        let mut store = NodeMetadataStore::new();
937
938        // Mix of macro and classpath metadata
939        store.insert(
940            NodeId::new(1, 0),
941            MacroNodeMetadata {
942                macro_generated: Some(true),
943                ..Default::default()
944            },
945        );
946
947        store.insert_typed(
948            NodeId::new(2, 0),
949            TypedMetadata::Classpath(ClasspathNodeMetadata {
950                coordinates: Some("org.slf4j:slf4j-api:2.0.0".to_string()),
951                jar_path: "slf4j-api-2.0.0.jar".to_string(),
952                fqn: "org.slf4j.Logger".to_string(),
953                is_direct_dependency: false,
954            }),
955        );
956
957        let bytes = postcard::to_allocvec(&store).expect("serialize");
958        let deserialized: NodeMetadataStore = postcard::from_bytes(&bytes).expect("deserialize");
959        assert_eq!(store, deserialized);
960        assert_eq!(deserialized.len(), 2);
961
962        // Verify macro entry
963        assert!(deserialized.get_macro(NodeId::new(1, 0)).is_some());
964
965        // Verify classpath entry via get_typed
966        let cp = deserialized.get_typed(NodeId::new(2, 0)).unwrap();
967        assert!(matches!(cp, TypedMetadata::Classpath(_)));
968    }
969
970    #[test]
971    fn test_node_metadata_store_json_roundtrip() {
972        let mut store = NodeMetadataStore::new();
973
974        store.insert(
975            NodeId::new(1, 0),
976            MacroNodeMetadata {
977                macro_generated: Some(true),
978                macro_source: Some("serde_derive".to_string()),
979                ..Default::default()
980            },
981        );
982
983        store.insert_typed(
984            NodeId::new(2, 0),
985            TypedMetadata::Classpath(ClasspathNodeMetadata {
986                coordinates: None,
987                jar_path: "rt.jar".to_string(),
988                fqn: "java.lang.String".to_string(),
989                is_direct_dependency: true,
990            }),
991        );
992
993        let json = serde_json::to_string(&store).unwrap();
994        let deserialized: NodeMetadataStore = serde_json::from_str(&json).unwrap();
995        assert_eq!(store, deserialized);
996    }
997
998    #[test]
999    fn test_iter_entries_includes_both_types() {
1000        let mut store = NodeMetadataStore::new();
1001
1002        store.insert(
1003            NodeId::new(1, 0),
1004            MacroNodeMetadata {
1005                macro_generated: Some(true),
1006                ..Default::default()
1007            },
1008        );
1009
1010        store.insert_typed(
1011            NodeId::new(2, 0),
1012            TypedMetadata::Classpath(ClasspathNodeMetadata {
1013                coordinates: None,
1014                jar_path: "test.jar".to_string(),
1015                fqn: "com.example.Test".to_string(),
1016                is_direct_dependency: true,
1017            }),
1018        );
1019
1020        // iter() only yields macro entries
1021        let macro_entries: Vec<_> = store.iter().collect();
1022        assert_eq!(macro_entries.len(), 1);
1023
1024        // iter_entries() yields all entries
1025        let all_entries: Vec<_> = store.iter_entries().collect();
1026        assert_eq!(all_entries.len(), 2);
1027    }
1028
1029    #[test]
1030    fn test_remove_entry_classpath() {
1031        let mut store = NodeMetadataStore::new();
1032        let node = NodeId::new(50, 0);
1033
1034        store.insert_typed(
1035            node,
1036            TypedMetadata::Classpath(ClasspathNodeMetadata {
1037                coordinates: None,
1038                jar_path: "test.jar".to_string(),
1039                fqn: "Test".to_string(),
1040                is_direct_dependency: true,
1041            }),
1042        );
1043
1044        assert_eq!(store.len(), 1);
1045
1046        // remove() returns None for non-macro entries, but still removes
1047        let removed = store.remove(node);
1048        assert!(removed.is_none());
1049        assert!(store.is_empty());
1050    }
1051
1052    #[test]
1053    fn test_remove_entry_typed() {
1054        let mut store = NodeMetadataStore::new();
1055        let node = NodeId::new(50, 0);
1056
1057        store.insert_typed(
1058            node,
1059            TypedMetadata::Classpath(ClasspathNodeMetadata {
1060                coordinates: None,
1061                jar_path: "test.jar".to_string(),
1062                fqn: "Test".to_string(),
1063                is_direct_dependency: true,
1064            }),
1065        );
1066
1067        // remove_entry() returns the full StoredEntry
1068        let removed = store.remove_entry(node);
1069        assert!(matches!(
1070            removed.as_ref().and_then(|e| e.typed.as_ref()),
1071            Some(TypedMetadata::Classpath(_))
1072        ));
1073        assert!(store.is_empty());
1074    }
1075
1076    // ------------------------------------------------------------------
1077    // U02_NODE_FLAGS: NodeFlags / StoredEntry / co-occurrence semantics
1078    // ------------------------------------------------------------------
1079
1080    mod node_flags_tests {
1081        use super::*;
1082
1083        #[test]
1084        fn node_flags_bit_composition() {
1085            // SYNTHETIC | ADDRESS_TAKEN coexist; setting one doesn't clear the other.
1086            let mut f = NodeFlags::EMPTY;
1087            assert!(f.is_empty());
1088            f.insert(NodeFlags::SYNTHETIC);
1089            assert!(f.contains(NodeFlags::SYNTHETIC));
1090            assert!(!f.contains(NodeFlags::ADDRESS_TAKEN));
1091
1092            f.insert(NodeFlags::ADDRESS_TAKEN);
1093            assert!(
1094                f.contains(NodeFlags::SYNTHETIC),
1095                "ADDRESS_TAKEN insert must not clear SYNTHETIC"
1096            );
1097            assert!(f.contains(NodeFlags::ADDRESS_TAKEN));
1098
1099            // contains(EMPTY) is trivially true.
1100            assert!(f.contains(NodeFlags::EMPTY));
1101
1102            // remove preserves the other bit.
1103            f.remove(NodeFlags::SYNTHETIC);
1104            assert!(!f.contains(NodeFlags::SYNTHETIC));
1105            assert!(f.contains(NodeFlags::ADDRESS_TAKEN));
1106
1107            // BitOr / BitOrAssign work for ergonomic construction.
1108            let combined = NodeFlags::SYNTHETIC | NodeFlags::CALLSITE_PROMISCUOUS;
1109            assert!(combined.contains(NodeFlags::SYNTHETIC));
1110            assert!(combined.contains(NodeFlags::CALLSITE_PROMISCUOUS));
1111            assert!(!combined.contains(NodeFlags::ADDRESS_TAKEN));
1112
1113            let mut acc = NodeFlags::EMPTY;
1114            acc |= NodeFlags::ADDRESS_TAKEN;
1115            acc |= NodeFlags::CALLSITE_PROMISCUOUS;
1116            assert!(acc.contains(NodeFlags::ADDRESS_TAKEN | NodeFlags::CALLSITE_PROMISCUOUS));
1117        }
1118
1119        #[test]
1120        fn stored_entry_flags_only_no_typed_payload() {
1121            // typed = None + flags = ADDRESS_TAKEN ONLY: pure marker entry.
1122            let entry = StoredEntry::with_flags(NodeFlags::ADDRESS_TAKEN);
1123            assert!(entry.typed.is_none());
1124            assert!(entry.flags.contains(NodeFlags::ADDRESS_TAKEN));
1125            assert!(!entry.flags.contains(NodeFlags::SYNTHETIC));
1126            assert!(!entry.is_vacant());
1127
1128            // A truly vacant entry is detectable.
1129            assert!(StoredEntry::default().is_vacant());
1130        }
1131
1132        #[test]
1133        fn co_occurrence_macro_and_address_taken() {
1134            // The design's whole point: TypedMetadata::Macro AND
1135            // NodeFlags::ADDRESS_TAKEN coexist in one entry. Both channels
1136            // return positively.
1137            let mut store = NodeMetadataStore::new();
1138            let node = NodeId::new(42, 1);
1139
1140            store.insert(
1141                node,
1142                MacroNodeMetadata {
1143                    macro_generated: Some(true),
1144                    macro_source: Some("DEFINE_HANDLER".to_string()),
1145                    ..Default::default()
1146                },
1147            );
1148            store.mark_address_taken(node);
1149
1150            // Typed channel: Macro payload preserved.
1151            let typed = store.get_typed(node).expect("typed entry present");
1152            assert!(matches!(typed, TypedMetadata::Macro(_)));
1153            let macro_meta = store.get_macro(node).expect("macro payload present");
1154            assert_eq!(macro_meta.macro_source.as_deref(), Some("DEFINE_HANDLER"));
1155
1156            // Flag channel: ADDRESS_TAKEN set.
1157            assert!(store.is_address_taken(node));
1158            assert!(!store.is_synthetic(node));
1159            assert!(!store.is_callsite_promiscuous(node));
1160
1161            // Adding another flag must NOT disturb the typed payload.
1162            store.mark_synthetic(node);
1163            assert!(store.is_synthetic(node));
1164            assert!(store.is_address_taken(node));
1165            assert!(
1166                store.get_macro(node).is_some(),
1167                "mark_synthetic must not clobber Macro payload"
1168            );
1169        }
1170
1171        #[test]
1172        fn synthetic_via_flag_not_typed() {
1173            // After mark_synthetic, get_typed == None AND is_synthetic == true
1174            // (no typed payload was created — the marker lives in flags alone).
1175            let mut store = NodeMetadataStore::new();
1176            let node = NodeId::new(7, 1);
1177
1178            assert!(!store.is_synthetic(node), "missing entry must report false");
1179
1180            store.mark_synthetic(node);
1181            assert!(store.is_synthetic(node));
1182            assert!(
1183                store.get_typed(node).is_none(),
1184                "mark_synthetic must not populate the typed slot"
1185            );
1186            assert!(store.get_macro(node).is_none());
1187
1188            // Stale generation must NOT see the synthetic flag.
1189            let stale = NodeId::new(7, 2);
1190            assert!(!store.is_synthetic(stale));
1191        }
1192
1193        #[test]
1194        fn mark_address_taken_preserves_typed_payload() {
1195            // Co-occurrence regression test: mark_address_taken on a node with
1196            // existing Macro payload must NOT replace or drop the payload.
1197            let mut store = NodeMetadataStore::new();
1198            let node = NodeId::new(101, 3);
1199            let macro_meta = MacroNodeMetadata {
1200                macro_generated: Some(true),
1201                macro_source: Some("foo_macro".to_string()),
1202                cfg_condition: Some("feature = \"x\"".to_string()),
1203                ..Default::default()
1204            };
1205            store.insert(node, macro_meta.clone());
1206
1207            store.mark_address_taken(node);
1208
1209            assert!(store.is_address_taken(node));
1210            let retrieved = store.get_macro(node).expect("Macro payload preserved");
1211            assert_eq!(retrieved, &macro_meta);
1212        }
1213
1214        #[test]
1215        fn mark_callsite_promiscuous_independent_of_typed() {
1216            // A callsite-promiscuous node may have no typed payload AND may
1217            // have any other flag set independently.
1218            let mut store = NodeMetadataStore::new();
1219            let node = NodeId::new(202, 0);
1220
1221            store.mark_callsite_promiscuous(node);
1222            assert!(store.is_callsite_promiscuous(node));
1223            assert!(!store.is_synthetic(node));
1224            assert!(!store.is_address_taken(node));
1225            assert!(store.get_typed(node).is_none());
1226
1227            // Add SYNTHETIC: must compose, not clobber.
1228            store.mark_synthetic(node);
1229            assert!(store.is_callsite_promiscuous(node));
1230            assert!(store.is_synthetic(node));
1231        }
1232
1233        #[test]
1234        fn get_flags_returns_empty_for_missing_entry() {
1235            let store = NodeMetadataStore::new();
1236            let missing = NodeId::new(999, 0);
1237            assert!(store.get_flags(missing).is_empty());
1238            assert!(!store.is_synthetic(missing));
1239            assert!(!store.is_address_taken(missing));
1240            assert!(!store.is_callsite_promiscuous(missing));
1241        }
1242
1243        #[test]
1244        fn get_macro_roundtrips_typed_macro_storage() {
1245            // get_macro returns Some for a TypedMetadata::Macro entry created
1246            // via either `insert` (convenience) or `insert_typed` (raw).
1247            let mut store = NodeMetadataStore::new();
1248            let node_a = NodeId::new(1, 0);
1249            let node_b = NodeId::new(2, 0);
1250            let payload_a = MacroNodeMetadata {
1251                cfg_condition: Some("a".to_string()),
1252                ..Default::default()
1253            };
1254            let payload_b = MacroNodeMetadata {
1255                cfg_condition: Some("b".to_string()),
1256                ..Default::default()
1257            };
1258
1259            store.insert(node_a, payload_a.clone());
1260            store.insert_typed(node_b, TypedMetadata::Macro(payload_b.clone()));
1261
1262            assert_eq!(store.get_macro(node_a), Some(&payload_a));
1263            assert_eq!(store.get_macro(node_b), Some(&payload_b));
1264        }
1265
1266        #[test]
1267        fn serialize_deserialize_preserves_typed_and_flags() {
1268            // Build a store mixing every shape: Macro+flags, Classpath alone,
1269            // flags-only (single + multi), and a stale-generation key.
1270            let mut store = NodeMetadataStore::new();
1271
1272            store.insert(
1273                NodeId::new(1, 0),
1274                MacroNodeMetadata {
1275                    macro_generated: Some(true),
1276                    ..Default::default()
1277                },
1278            );
1279            store.mark_address_taken(NodeId::new(1, 0));
1280
1281            store.insert_typed(
1282                NodeId::new(2, 0),
1283                TypedMetadata::Classpath(ClasspathNodeMetadata {
1284                    coordinates: None,
1285                    jar_path: "x.jar".to_string(),
1286                    fqn: "com.example.X".to_string(),
1287                    is_direct_dependency: true,
1288                }),
1289            );
1290
1291            store.mark_synthetic(NodeId::new(3, 0));
1292            store.mark_address_taken(NodeId::new(4, 9));
1293            store.mark_callsite_promiscuous(NodeId::new(4, 9));
1294
1295            let bytes = postcard::to_allocvec(&store).expect("serialize");
1296            let decoded: NodeMetadataStore = postcard::from_bytes(&bytes).expect("deserialize");
1297
1298            assert_eq!(store, decoded);
1299
1300            // Spot-check that both channels round-tripped, not just equality.
1301            assert!(decoded.get_macro(NodeId::new(1, 0)).is_some());
1302            assert!(decoded.is_address_taken(NodeId::new(1, 0)));
1303            assert!(matches!(
1304                decoded.get_typed(NodeId::new(2, 0)),
1305                Some(TypedMetadata::Classpath(_))
1306            ));
1307            assert!(decoded.is_synthetic(NodeId::new(3, 0)));
1308            assert!(decoded.is_address_taken(NodeId::new(4, 9)));
1309            assert!(decoded.is_callsite_promiscuous(NodeId::new(4, 9)));
1310        }
1311
1312        #[test]
1313        fn json_serialize_deserialize_preserves_typed_and_flags() {
1314            // serde_json path used by MCP export. Same shape as the postcard
1315            // round-trip — the Serialize/Deserialize impls are wire-format
1316            // agnostic.
1317            let mut store = NodeMetadataStore::new();
1318            store.insert(
1319                NodeId::new(5, 5),
1320                MacroNodeMetadata {
1321                    macro_generated: Some(true),
1322                    ..Default::default()
1323                },
1324            );
1325            store.mark_address_taken(NodeId::new(5, 5));
1326            store.mark_synthetic(NodeId::new(9, 0));
1327
1328            let json = serde_json::to_string(&store).expect("json serialize");
1329            let decoded: NodeMetadataStore = serde_json::from_str(&json).expect("json deserialize");
1330            assert_eq!(store, decoded);
1331        }
1332
1333        #[test]
1334        fn insert_entry_bulk_remap_path() {
1335            // The classpath emitter rebuilds the store from `iter_entries()`
1336            // + `insert_entry()` during id remapping; that path must preserve
1337            // both channels.
1338            let mut original = NodeMetadataStore::new();
1339            original.insert_typed(
1340                NodeId::new(10, 0),
1341                TypedMetadata::Classpath(ClasspathNodeMetadata {
1342                    coordinates: Some("g:a:1".to_string()),
1343                    jar_path: "j.jar".to_string(),
1344                    fqn: "F".to_string(),
1345                    is_direct_dependency: true,
1346                }),
1347            );
1348            original.mark_address_taken(NodeId::new(10, 0));
1349            original.mark_synthetic(NodeId::new(11, 0));
1350
1351            // Simulate the emitter's remap: copy via iter_entries.
1352            let mut remapped = NodeMetadataStore::new();
1353            for (key, entry) in original.iter_entries() {
1354                let nid = NodeId::new(key.0, key.1);
1355                remapped.insert_entry(nid, entry.clone());
1356            }
1357
1358            assert_eq!(original, remapped);
1359            assert!(remapped.is_address_taken(NodeId::new(10, 0)));
1360            assert!(matches!(
1361                remapped.get_typed(NodeId::new(10, 0)),
1362                Some(TypedMetadata::Classpath(_))
1363            ));
1364            assert!(remapped.is_synthetic(NodeId::new(11, 0)));
1365        }
1366    }
1367}