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