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, ¯o_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}