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