Skip to main content

fallow_graph/graph/
types.rs

1//! Shared graph types: module nodes, re-export edges, export symbols, and references.
2
3use std::cmp::Ordering;
4use std::num::NonZeroU32;
5use std::ops::Range;
6use std::path::PathBuf;
7
8use fallow_types::discover::FileId;
9use fallow_types::extract::{ExportName, ModuleLoadMechanism, VisibilityTag};
10use rustc_hash::{FxHashMap, FxHashSet};
11
12/// A single module in the graph.
13///
14/// Boolean flags are packed into a `u8` to keep the struct at 96 bytes
15/// (down from 104 with 5 separate `bool` fields), improving cache line
16/// utilization in hot graph traversal loops.
17#[derive(Debug, serde::Serialize, serde::Deserialize)]
18pub struct ModuleNode {
19    /// Unique identifier for this module.
20    pub file_id: FileId,
21    /// Absolute path to the module file.
22    pub path: PathBuf,
23    /// Range into the flat `edges` array.
24    pub edge_range: Range<usize>,
25    /// Exports declared by this module.
26    pub exports: Vec<ExportSymbol>,
27    /// Re-exports from this module (export { x } from './y', export * from './z').
28    pub re_exports: Vec<ReExportEdge>,
29    /// Packed boolean flags (entry point, reachability, CJS).
30    pub(crate) flags: u8,
31}
32
33const FLAG_ENTRY_POINT: u8 = 1 << 0;
34const FLAG_REACHABLE: u8 = 1 << 1;
35const FLAG_RUNTIME_REACHABLE: u8 = 1 << 2;
36const FLAG_TEST_REACHABLE: u8 = 1 << 3;
37const FLAG_CJS_EXPORTS: u8 = 1 << 4;
38
39impl ModuleNode {
40    /// Whether this module is an entry point.
41    #[inline]
42    pub const fn is_entry_point(&self) -> bool {
43        self.flags & FLAG_ENTRY_POINT != 0
44    }
45
46    /// Whether this module is reachable from any entry point.
47    #[inline]
48    pub const fn is_reachable(&self) -> bool {
49        self.flags & FLAG_REACHABLE != 0
50    }
51
52    /// Whether this module is reachable from a runtime/application root.
53    #[inline]
54    pub const fn is_runtime_reachable(&self) -> bool {
55        self.flags & FLAG_RUNTIME_REACHABLE != 0
56    }
57
58    /// Whether this module is reachable from a test root.
59    #[inline]
60    pub const fn is_test_reachable(&self) -> bool {
61        self.flags & FLAG_TEST_REACHABLE != 0
62    }
63
64    /// Whether this module has CJS exports (module.exports / exports.*).
65    #[inline]
66    pub const fn has_cjs_exports(&self) -> bool {
67        self.flags & FLAG_CJS_EXPORTS != 0
68    }
69
70    /// Whether the module object of this module is its forwarded default
71    /// export, as with `import x from './y'; export = x`.
72    ///
73    /// Each read of a member of the module object (`m.run()` on a require
74    /// binding, `import { run }`, `const { run } = require(..)`) reads a member
75    /// of that default export (issue #3392). A file that mixes CommonJS
76    /// exports with an ES `default` re-export also matches. The extra credit
77    /// that follows can only hide a finding, never add one.
78    #[must_use]
79    pub fn forwards_default_as_module_object(&self) -> bool {
80        self.has_cjs_exports()
81            && self
82                .re_exports
83                .iter()
84                .any(|re_export| re_export.exported_name == "default")
85    }
86
87    /// Set whether this module is reachable from any entry point.
88    #[inline]
89    pub fn set_reachable(&mut self, v: bool) {
90        if v {
91            self.flags |= FLAG_REACHABLE;
92        } else {
93            self.flags &= !FLAG_REACHABLE;
94        }
95    }
96
97    /// Set whether this module is reachable from a runtime/application root.
98    #[inline]
99    pub(crate) fn set_runtime_reachable(&mut self, v: bool) {
100        if v {
101            self.flags |= FLAG_RUNTIME_REACHABLE;
102        } else {
103            self.flags &= !FLAG_RUNTIME_REACHABLE;
104        }
105    }
106
107    /// Set whether this module is reachable from a test root.
108    #[inline]
109    pub(crate) fn set_test_reachable(&mut self, v: bool) {
110        if v {
111            self.flags |= FLAG_TEST_REACHABLE;
112        } else {
113            self.flags &= !FLAG_TEST_REACHABLE;
114        }
115    }
116
117    /// Set whether this module has CJS exports.
118    #[inline]
119    pub fn set_cjs_exports(&mut self, v: bool) {
120        if v {
121            self.flags |= FLAG_CJS_EXPORTS;
122        } else {
123            self.flags &= !FLAG_CJS_EXPORTS;
124        }
125    }
126
127    /// Build flags byte from individual booleans (used by graph construction).
128    #[inline]
129    pub(crate) fn flags_from(
130        is_entry_point: bool,
131        is_runtime_reachable: bool,
132        has_cjs_exports: bool,
133    ) -> u8 {
134        let mut f = 0u8;
135        if is_entry_point {
136            f |= FLAG_ENTRY_POINT;
137        }
138        if is_runtime_reachable {
139            f |= FLAG_RUNTIME_REACHABLE;
140        }
141        if has_cjs_exports {
142            f |= FLAG_CJS_EXPORTS;
143        }
144        f
145    }
146}
147
148/// A re-export edge, tracking which exports are forwarded from which module.
149#[derive(Debug, serde::Serialize, serde::Deserialize)]
150pub struct ReExportEdge {
151    /// The module being re-exported from.
152    pub source_file: FileId,
153    /// The name imported from the source (or "*" for star re-exports).
154    pub imported_name: String,
155    /// The name exported from this module.
156    pub exported_name: String,
157    /// Whether this is a type-only re-export.
158    pub is_type_only: bool,
159    /// Source span of the re-export declaration on this module, used for
160    /// line-number reporting. `(0, 0)` for re-exports synthesized inside the
161    /// graph layer (e.g., `export *` chain propagation, namespace narrowing).
162    #[serde(with = "crate::cache::span_serde")]
163    pub span: oxc_span::Span,
164}
165
166/// An export with reference tracking.
167#[derive(Debug, serde::Serialize, serde::Deserialize)]
168pub struct ExportSymbol {
169    /// The exported name (named or default).
170    pub name: ExportName,
171    /// Whether this is a type-only export.
172    pub is_type_only: bool,
173    /// Whether this export is registered through a runtime side effect at module
174    /// load time (e.g. a Lit `@customElement('tag')` decorator or a
175    /// `customElements.define('tag', ClassRef)` call). The unused-export
176    /// detector treats this as an effective reference.
177    pub is_side_effect_used: bool,
178    /// Visibility tag from JSDoc/TSDoc comment (`@public`, `@internal`, `@alpha`, `@beta`).
179    /// Exports with any visibility tag are never reported as unused.
180    pub visibility: VisibilityTag,
181    /// Human-authored reason on `@expected-unused -- <reason>`, when present.
182    pub expected_unused_reason: Option<String>,
183    /// Whether the leading JSDoc carries a `@deprecated` tag.
184    pub deprecated: bool,
185    /// Plain-text `@deprecated` message, `None` for a bare tag.
186    pub deprecated_reason: Option<Box<str>>,
187    /// Source span of the export declaration.
188    #[serde(with = "crate::cache::span_serde")]
189    pub span: oxc_span::Span,
190    /// Which files reference this export.
191    pub references: Vec<SymbolReference>,
192    /// Interned provenance paths parallel to `references`, keyed by reference
193    /// index (issue #2083).
194    ///
195    /// Only populated when the test-reachability plan requires reference
196    /// provenance (a replacement mock exists). It stays empty for every other
197    /// project so the reference list itself remains 16 bytes per entry and the
198    /// side table allocates nothing. Entries can be `None` even when populated:
199    /// legacy reachability stores no path, profiled reachability always does.
200    #[serde(default)]
201    pub reference_paths: Vec<Option<ReferencePathId>>,
202    /// Members of this export (enum members, class members).
203    ///
204    /// `MemberInfo` is a shared `fallow-types` struct whose serde shape is
205    /// serialize-only (its `span` uses `serialize_with` with no matching
206    /// deserializer), so it cannot round-trip through a plain derive. The cache
207    /// routes it through a dedicated lossless mirror in `crate::cache`.
208    #[serde(with = "crate::cache::member_serde")]
209    pub members: Vec<fallow_types::extract::MemberInfo>,
210}
211
212/// A reference to an export from another file.
213#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
214pub struct SymbolReference {
215    /// The file that references this export.
216    pub from_file: FileId,
217    /// How the export is referenced.
218    pub kind: ReferenceKind,
219    /// Semantic namespace used by this reference.
220    pub namespace: super::ExportNamespace,
221    /// Byte span of the import statement in the referencing file.
222    /// Used by the LSP to locate references for Code Lens navigation.
223    #[serde(with = "crate::cache::span_serde")]
224    pub import_span: oxc_span::Span,
225}
226
227/// Compact identifier for an interned reference path.
228///
229/// Opaque outside the graph crate; it only appears in the public API as the
230/// element type of [`ExportSymbol::reference_paths`].
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
232pub struct ReferencePathId(NonZeroU32);
233
234/// A symbol reference paired with its optional provenance path while build
235/// passes route it between exports, before it lands in a reference list plus
236/// its provenance side table.
237#[derive(Clone, Copy)]
238pub(crate) struct RoutedReference {
239    pub(crate) reference: SymbolReference,
240    pub(crate) path: Option<ReferencePathId>,
241}
242
243pub(crate) type RoutedReferenceKey = (
244    FileId,
245    oxc_span::Span,
246    Option<ReferencePathId>,
247    super::ExportNamespace,
248);
249
250impl RoutedReference {
251    pub(crate) const fn key(self) -> RoutedReferenceKey {
252        (
253            self.reference.from_file,
254            self.reference.import_span,
255            self.path,
256            self.reference.namespace,
257        )
258    }
259}
260
261impl ExportSymbol {
262    const INLINE_PHYSICAL_REFERENCE_LIMIT: usize = 8;
263
264    /// References that use one semantic namespace.
265    pub fn references_in(
266        &self,
267        namespace: super::ExportNamespace,
268    ) -> impl Iterator<Item = &SymbolReference> {
269        self.references
270            .iter()
271            .filter(move |reference| reference.namespace == namespace)
272    }
273
274    /// Distinct physical reference sites, collapsing Type and Value uses of
275    /// the same import while preserving different resolved routes.
276    pub fn physical_references(&self) -> impl Iterator<Item = &SymbolReference> {
277        let mut seen = (self.references.len() > Self::INLINE_PHYSICAL_REFERENCE_LIMIT)
278            .then(FxHashSet::default);
279        self.references
280            .iter()
281            .enumerate()
282            .filter(move |(index, reference)| {
283                let key = (
284                    reference.from_file,
285                    reference.import_span,
286                    self.reference_path(*index),
287                );
288                if let Some(seen) = &mut seen {
289                    return seen.insert(key);
290                }
291                !(0..*index).any(|prior_index| {
292                    let prior = &self.references[prior_index];
293                    key == (
294                        prior.from_file,
295                        prior.import_span,
296                        self.reference_path(prior_index),
297                    )
298                })
299            })
300            .map(|(_, reference)| reference)
301    }
302
303    /// Provenance path recorded for the reference at `index`, when tracked.
304    pub(crate) fn reference_path(&self, index: usize) -> Option<ReferencePathId> {
305        self.reference_paths.get(index).copied().flatten()
306    }
307
308    /// Whether a reference from `from_file` with this exact provenance path is
309    /// already attached.
310    pub(crate) fn has_reference_from(
311        &self,
312        from_file: FileId,
313        import_span: oxc_span::Span,
314        path: Option<ReferencePathId>,
315        namespace: super::ExportNamespace,
316    ) -> bool {
317        self.references
318            .iter()
319            .enumerate()
320            .any(|(index, reference)| {
321                reference.from_file == from_file
322                    && reference.import_span == import_span
323                    && self.reference_path(index) == path
324                    && reference.namespace == namespace
325            })
326    }
327
328    /// Attach `reference`, recording `path` in the provenance side table.
329    ///
330    /// The side table stays untouched until the first tracked path arrives, so
331    /// projects without replacement mocks never allocate it.
332    pub(crate) fn push_reference(
333        &mut self,
334        reference: SymbolReference,
335        path: Option<ReferencePathId>,
336    ) {
337        if path.is_some() || !self.reference_paths.is_empty() {
338            self.reference_paths.resize(self.references.len(), None);
339            self.reference_paths.push(path);
340        }
341        self.references.push(reference);
342    }
343
344    /// Iterate references together with their recorded provenance paths.
345    pub(crate) fn routed_references(&self) -> impl Iterator<Item = RoutedReference> + '_ {
346        self.references
347            .iter()
348            .enumerate()
349            .map(|(index, reference)| RoutedReference {
350                reference: *reference,
351                path: self.reference_path(index),
352            })
353    }
354}
355
356/// One conjunctive step in an interned export-reference route.
357#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
358pub(crate) enum ReferencePathNode {
359    /// One ordinary module-load hop.
360    Hop {
361        /// Previous conjunctive step, or `None` for a direct load.
362        parent: Option<ReferencePathId>,
363        /// Module loaded by this hop.
364        target: FileId,
365        /// Runtime mechanism used by this hop.
366        mechanism: ModuleLoadMechanism,
367    },
368    /// One existential traversal through a compact namespace transition graph.
369    Route {
370        /// Previous conjunctive step, used when a namespace route is followed
371        /// by another namespace segment.
372        parent: Option<ReferencePathId>,
373        /// Canonical transition graph containing `start` and `terminal`.
374        graph: ReferenceRouteGraphId,
375        /// Local graph node where traversal begins.
376        start: ReferenceRouteNodeId,
377        /// Local graph node that must be reachable.
378        terminal: ReferenceRouteNodeId,
379        /// Mechanism used by the consumer to load `start`. `None` means the
380        /// reference source already owns the start module (entry points and
381        /// concatenated route segments).
382        start_mechanism: Option<ModuleLoadMechanism>,
383    },
384}
385
386impl ReferencePathNode {
387    pub(crate) const fn parent(self) -> Option<ReferencePathId> {
388        match self {
389            Self::Hop { parent, .. } | Self::Route { parent, .. } => parent,
390        }
391    }
392
393    fn remap_parent(&mut self, remap: &[ReferencePathId]) {
394        match self {
395            Self::Hop { parent, .. } | Self::Route { parent, .. } => {
396                *parent = parent.map(|path| remap[path.index()]);
397            }
398        }
399    }
400}
401
402/// Build-time identifier for one compact namespace transition graph.
403#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
404pub(crate) struct ReferenceRouteGraphId(pub(crate) u32);
405
406/// Node identifier local to one namespace transition graph.
407#[derive(
408    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
409)]
410pub(crate) struct ReferenceRouteNodeId(pub(crate) u32);
411
412/// One canonical node in a build-time namespace transition graph.
413#[derive(Debug, Clone, PartialEq, Eq, Hash)]
414pub(crate) struct ReferenceRouteNodeSpec {
415    target: FileId,
416    mechanism: ModuleLoadMechanism,
417    successors: Vec<ReferenceRouteNodeId>,
418}
419
420impl ReferenceRouteNodeSpec {
421    pub(crate) fn new(
422        target: FileId,
423        mechanism: ModuleLoadMechanism,
424        mut successors: Vec<ReferenceRouteNodeId>,
425    ) -> Self {
426        successors.sort_unstable_by_key(|successor| successor.0);
427        successors.dedup();
428        Self {
429            target,
430            mechanism,
431            successors,
432        }
433    }
434}
435
436/// Canonical build-time representation of one namespace transition graph.
437#[derive(Debug, Clone, PartialEq, Eq, Hash)]
438pub(crate) struct ReferenceRouteGraphSpec {
439    nodes: Vec<ReferenceRouteNodeSpec>,
440}
441
442impl ReferenceRouteGraphSpec {
443    pub(crate) fn new(nodes: Vec<ReferenceRouteNodeSpec>) -> Self {
444        debug_assert!(nodes.iter().all(|node| {
445            node.successors
446                .iter()
447                .all(|successor| successor.0 < nodes.len() as u32)
448        }));
449        Self { nodes }
450    }
451}
452
453/// Persisted range for one canonical namespace transition graph.
454#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
455pub(crate) struct ReferenceRouteGraph {
456    pub(crate) nodes: Range<u32>,
457}
458
459/// One persisted namespace transition node.
460#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
461pub(crate) struct ReferenceRouteNode {
462    pub(crate) target: FileId,
463    pub(crate) mechanism: ModuleLoadMechanism,
464    pub(crate) successors: Range<u32>,
465}
466
467/// Cache-friendly persisted namespace transition graphs.
468#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
469pub(crate) struct ReferenceRoutes {
470    pub(crate) graphs: Vec<ReferenceRouteGraph>,
471    pub(crate) nodes: Vec<ReferenceRouteNode>,
472    pub(crate) edges: Vec<ReferenceRouteNodeId>,
473}
474
475impl ReferenceRoutes {
476    #[cfg(test)]
477    pub(crate) fn canonical_hops(
478        &self,
479        graph_id: ReferenceRouteGraphId,
480        start: ReferenceRouteNodeId,
481        terminal: ReferenceRouteNodeId,
482        start_mechanism: Option<ModuleLoadMechanism>,
483    ) -> Vec<(FileId, ModuleLoadMechanism)> {
484        let Some(graph) = self.graphs.get(graph_id.0 as usize) else {
485            return Vec::new();
486        };
487        let node_count = graph.nodes.end.saturating_sub(graph.nodes.start) as usize;
488        let start_index = start.0 as usize;
489        let terminal_index = terminal.0 as usize;
490        if start_index >= node_count || terminal_index >= node_count {
491            return Vec::new();
492        }
493
494        let mut predecessor = vec![None; node_count];
495        let mut visited = vec![false; node_count];
496        let mut queue = std::collections::VecDeque::from([start_index]);
497        visited[start_index] = true;
498        while let Some(local_index) = queue.pop_front() {
499            if local_index == terminal_index {
500                break;
501            }
502            let Some(node) = self.nodes.get(graph.nodes.start as usize + local_index) else {
503                return Vec::new();
504            };
505            let Some(successors) = self
506                .edges
507                .get(node.successors.start as usize..node.successors.end as usize)
508            else {
509                return Vec::new();
510            };
511            for successor in successors {
512                let successor_index = successor.0 as usize;
513                if successor_index >= node_count || visited[successor_index] {
514                    continue;
515                }
516                visited[successor_index] = true;
517                predecessor[successor_index] = Some(local_index);
518                queue.push_back(successor_index);
519            }
520        }
521        if !visited[terminal_index] {
522            return Vec::new();
523        }
524
525        let mut hops = Vec::new();
526        let mut current = terminal_index;
527        loop {
528            let node = &self.nodes[graph.nodes.start as usize + current];
529            if current == start_index {
530                if let Some(mechanism) = start_mechanism {
531                    hops.push((node.target, mechanism));
532                }
533                break;
534            }
535            hops.push((node.target, node.mechanism));
536            let Some(parent) = predecessor[current] else {
537                return Vec::new();
538            };
539            current = parent;
540        }
541        hops
542    }
543}
544
545/// Finalized linear paths plus compact namespace transition graphs.
546#[derive(Debug, PartialEq, Eq)]
547pub(crate) struct FinalizedReferencePaths {
548    pub(crate) paths: Vec<ReferencePathNode>,
549    pub(crate) routes: ReferenceRoutes,
550}
551
552/// Build-time interner for shared linked reference paths.
553pub(crate) struct ReferencePathInterner {
554    track_provenance: bool,
555    nodes: Vec<ReferencePathNode>,
556    metadata: Vec<ReferencePathMetadata>,
557    ids: FxHashMap<ReferencePathNode, ReferencePathId>,
558    route_graphs: Vec<ReferenceRouteGraphSpec>,
559    route_graph_ids: FxHashMap<ReferenceRouteGraphSpec, ReferenceRouteGraphId>,
560}
561
562#[derive(Clone, Copy)]
563struct ReferencePathMetadata {
564    depth: usize,
565    hop_target_bounds: Option<(FileId, FileId)>,
566}
567
568impl Default for ReferencePathInterner {
569    fn default() -> Self {
570        Self::new(true)
571    }
572}
573
574impl ReferencePathInterner {
575    pub(crate) fn new(track_provenance: bool) -> Self {
576        Self {
577            track_provenance,
578            nodes: Vec::new(),
579            metadata: Vec::new(),
580            ids: FxHashMap::default(),
581            route_graphs: Vec::new(),
582            route_graph_ids: FxHashMap::default(),
583        }
584    }
585
586    pub(crate) const fn tracks_provenance(&self) -> bool {
587        self.track_provenance
588    }
589
590    /// Intern a direct consumer-to-target path.
591    pub(crate) fn direct(
592        &mut self,
593        target: FileId,
594        mechanism: ModuleLoadMechanism,
595    ) -> Option<ReferencePathId> {
596        self.track_provenance.then(|| {
597            self.intern(ReferencePathNode::Hop {
598                parent: None,
599                target,
600                mechanism,
601            })
602        })
603    }
604
605    /// Append one typed hop to an existing path.
606    pub(crate) fn extend(
607        &mut self,
608        parent: Option<ReferencePathId>,
609        target: FileId,
610        mechanism: ModuleLoadMechanism,
611    ) -> Option<ReferencePathId> {
612        if !self.track_provenance {
613            debug_assert!(parent.is_none());
614            return None;
615        }
616        let Some(parent) = parent else {
617            debug_assert!(false, "tracked reference paths require an interned parent");
618            return None;
619        };
620        let may_contain_target = self
621            .metadata
622            .get(parent.index())
623            .and_then(|metadata| metadata.hop_target_bounds)
624            .is_some_and(|(minimum, maximum)| target.0 >= minimum.0 && target.0 <= maximum.0);
625        if may_contain_target && self.contains_target(parent, target) {
626            return Some(parent);
627        }
628        Some(self.intern(ReferencePathNode::Hop {
629            parent: Some(parent),
630            target,
631            mechanism,
632        }))
633    }
634
635    /// Intern one compact namespace transition graph.
636    pub(crate) fn intern_route_graph(
637        &mut self,
638        graph: ReferenceRouteGraphSpec,
639    ) -> ReferenceRouteGraphId {
640        debug_assert!(self.track_provenance);
641        if let Some(id) = self.route_graph_ids.get(&graph) {
642            return *id;
643        }
644        let id = ReferenceRouteGraphId(self.route_graphs.len() as u32);
645        self.route_graphs.push(graph.clone());
646        self.route_graph_ids.insert(graph, id);
647        id
648    }
649
650    /// Intern one existential traversal through a compact route graph.
651    pub(crate) fn route(
652        &mut self,
653        parent: Option<ReferencePathId>,
654        graph: ReferenceRouteGraphId,
655        start: ReferenceRouteNodeId,
656        terminal: ReferenceRouteNodeId,
657        start_mechanism: Option<ModuleLoadMechanism>,
658    ) -> Option<ReferencePathId> {
659        if !self.track_provenance {
660            return None;
661        }
662        Some(self.intern(ReferencePathNode::Route {
663            parent,
664            graph,
665            start,
666            terminal,
667            start_mechanism,
668        }))
669    }
670
671    fn contains_target(&self, mut path: ReferencePathId, target: FileId) -> bool {
672        loop {
673            let Some(node) = self.nodes.get(path.index()) else {
674                return false;
675            };
676            if let ReferencePathNode::Hop {
677                target: hop_target, ..
678            } = node
679                && *hop_target == target
680            {
681                return true;
682            }
683            let Some(parent) = node.parent() else {
684                return false;
685            };
686            path = parent;
687        }
688    }
689
690    fn intern(&mut self, node: ReferencePathNode) -> ReferencePathId {
691        if let Some(path) = self.ids.get(&node) {
692            return *path;
693        }
694        let path = ReferencePathId::from_index(self.nodes.len());
695        let parent_metadata = node
696            .parent()
697            .and_then(|parent| self.metadata.get(parent.index()).copied());
698        let depth = parent_metadata.map_or(0, |metadata| metadata.depth + 1);
699        let hop_target_bounds = match node {
700            ReferencePathNode::Hop { target, .. } => Some(
701                parent_metadata
702                    .and_then(|metadata| metadata.hop_target_bounds)
703                    .map_or((target, target), |(minimum, maximum)| {
704                        (
705                            FileId(minimum.0.min(target.0)),
706                            FileId(maximum.0.max(target.0)),
707                        )
708                    }),
709            ),
710            ReferencePathNode::Route { .. } => {
711                parent_metadata.and_then(|metadata| metadata.hop_target_bounds)
712            }
713        };
714        self.nodes.push(node);
715        self.metadata.push(ReferencePathMetadata {
716            depth,
717            hop_target_bounds,
718        });
719        self.ids.insert(node, path);
720        path
721    }
722
723    /// Finalize cache-friendly storage and assign canonical IDs.
724    ///
725    /// Paths are ordered depth-by-depth so every parent already has its final
726    /// ID before its children are sorted. This keeps serialized graphs stable
727    /// when equivalent imports or re-exports are discovered in another order.
728    pub(crate) fn finalize(self, modules: &mut [ModuleNode]) -> FinalizedReferencePaths {
729        if self.nodes.is_empty() && self.route_graphs.is_empty() {
730            return FinalizedReferencePaths {
731                paths: Vec::new(),
732                routes: ReferenceRoutes::default(),
733            };
734        }
735
736        let (routes, route_remap) = finalize_route_graphs(&self.route_graphs);
737        let max_depth = self
738            .metadata
739            .iter()
740            .map(|metadata| metadata.depth)
741            .max()
742            .unwrap_or(0);
743
744        let mut paths_by_depth = vec![Vec::new(); max_depth.saturating_add(1)];
745        for (old_index, metadata) in self.metadata.iter().enumerate() {
746            paths_by_depth[metadata.depth].push(old_index);
747        }
748
749        let mut remap = vec![ReferencePathId::from_index(0); self.nodes.len()];
750        let mut finalized = Vec::with_capacity(self.nodes.len());
751        for mut paths in paths_by_depth {
752            paths.sort_unstable_by(|&left, &right| {
753                compare_path_nodes(self.nodes[left], self.nodes[right], &remap, &route_remap)
754            });
755            for old_index in paths {
756                let mut node = self.nodes[old_index];
757                node.remap_parent(&remap);
758                if let ReferencePathNode::Route { graph, .. } = &mut node {
759                    *graph = route_remap[graph.0 as usize];
760                }
761                let canonical = ReferencePathId::from_index(finalized.len());
762                remap[old_index] = canonical;
763                finalized.push(node);
764            }
765        }
766
767        for path in modules
768            .iter_mut()
769            .flat_map(|module| &mut module.exports)
770            .flat_map(|export| &mut export.reference_paths)
771        {
772            if let Some(existing) = *path {
773                *path = Some(remap[existing.index()]);
774            }
775        }
776
777        FinalizedReferencePaths {
778            paths: finalized,
779            routes,
780        }
781    }
782}
783
784fn compare_path_nodes(
785    left: ReferencePathNode,
786    right: ReferencePathNode,
787    path_remap: &[ReferencePathId],
788    route_remap: &[ReferenceRouteGraphId],
789) -> Ordering {
790    let left_parent = left.parent().map(|parent| path_remap[parent.index()].0);
791    let right_parent = right.parent().map(|parent| path_remap[parent.index()].0);
792    left_parent
793        .cmp(&right_parent)
794        .then_with(|| match (left, right) {
795            (
796                ReferencePathNode::Hop {
797                    target: left_target,
798                    mechanism: left_mechanism,
799                    ..
800                },
801                ReferencePathNode::Hop {
802                    target: right_target,
803                    mechanism: right_mechanism,
804                    ..
805                },
806            ) => {
807                (left_target.0, left_mechanism as u8).cmp(&(right_target.0, right_mechanism as u8))
808            }
809            (ReferencePathNode::Hop { .. }, ReferencePathNode::Route { .. }) => Ordering::Less,
810            (ReferencePathNode::Route { .. }, ReferencePathNode::Hop { .. }) => Ordering::Greater,
811            (
812                ReferencePathNode::Route {
813                    graph: left_graph,
814                    start: left_start,
815                    terminal: left_terminal,
816                    start_mechanism: left_mechanism,
817                    ..
818                },
819                ReferencePathNode::Route {
820                    graph: right_graph,
821                    start: right_start,
822                    terminal: right_terminal,
823                    start_mechanism: right_mechanism,
824                    ..
825                },
826            ) => (
827                route_remap[left_graph.0 as usize].0,
828                left_start.0,
829                left_terminal.0,
830                left_mechanism.map(|mechanism| mechanism as u8),
831            )
832                .cmp(&(
833                    route_remap[right_graph.0 as usize].0,
834                    right_start.0,
835                    right_terminal.0,
836                    right_mechanism.map(|mechanism| mechanism as u8),
837                )),
838        })
839}
840
841fn compare_route_graph_specs(
842    left: &ReferenceRouteGraphSpec,
843    right: &ReferenceRouteGraphSpec,
844) -> Ordering {
845    left.nodes.len().cmp(&right.nodes.len()).then_with(|| {
846        left.nodes
847            .iter()
848            .zip(&right.nodes)
849            .find_map(|(left_node, right_node)| {
850                let ordering = (
851                    left_node.target.0,
852                    left_node.mechanism as u8,
853                    &left_node.successors,
854                )
855                    .cmp(&(
856                        right_node.target.0,
857                        right_node.mechanism as u8,
858                        &right_node.successors,
859                    ));
860                (ordering != Ordering::Equal).then_some(ordering)
861            })
862            .unwrap_or(Ordering::Equal)
863    })
864}
865
866fn finalize_route_graphs(
867    graphs: &[ReferenceRouteGraphSpec],
868) -> (ReferenceRoutes, Vec<ReferenceRouteGraphId>) {
869    let mut order: Vec<usize> = (0..graphs.len()).collect();
870    order
871        .sort_unstable_by(|&left, &right| compare_route_graph_specs(&graphs[left], &graphs[right]));
872
873    let mut remap = vec![ReferenceRouteGraphId(0); graphs.len()];
874    let mut finalized = ReferenceRoutes::default();
875    for old_index in order {
876        let graph_id = ReferenceRouteGraphId(finalized.graphs.len() as u32);
877        remap[old_index] = graph_id;
878        let node_start = finalized.nodes.len() as u32;
879        for node in &graphs[old_index].nodes {
880            let edge_start = finalized.edges.len() as u32;
881            finalized.edges.extend_from_slice(&node.successors);
882            finalized.nodes.push(ReferenceRouteNode {
883                target: node.target,
884                mechanism: node.mechanism,
885                successors: edge_start..finalized.edges.len() as u32,
886            });
887        }
888        finalized.graphs.push(ReferenceRouteGraph {
889            nodes: node_start..finalized.nodes.len() as u32,
890        });
891    }
892    (finalized, remap)
893}
894
895impl ReferencePathId {
896    fn from_index(index: usize) -> Self {
897        let Some(encoded) = u32::try_from(index)
898            .ok()
899            .and_then(|index| index.checked_add(1))
900            .and_then(NonZeroU32::new)
901        else {
902            panic!("a process cannot allocate more than u32::MAX reference path nodes");
903        };
904        Self(encoded)
905    }
906
907    pub(crate) const fn index(self) -> usize {
908        (self.0.get() - 1) as usize
909    }
910}
911
912/// How an export is referenced.
913#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
914pub enum ReferenceKind {
915    /// A named import (`import { foo }`).
916    NamedImport,
917    /// A default import (`import Foo`).
918    DefaultImport,
919    /// A namespace import (`import * as ns`).
920    NamespaceImport,
921    /// A re-export (`export { foo } from './bar'`).
922    ReExport,
923    /// A dynamic import (`import('./foo')`).
924    DynamicImport,
925    /// A side-effect import (`import './styles'`).
926    SideEffectImport,
927}
928
929#[cfg(target_pointer_width = "64")]
930const _: () = assert!(std::mem::size_of::<ExportSymbol>() == 152);
931#[cfg(target_pointer_width = "64")]
932const _: () = assert!(std::mem::size_of::<SymbolReference>() == 16);
933#[cfg(target_pointer_width = "64")]
934const _: () = assert!(std::mem::size_of::<ReExportEdge>() == 64);
935#[cfg(all(target_pointer_width = "64", unix))]
936const _: () = assert!(std::mem::size_of::<ModuleNode>() == 96);
937
938#[cfg(test)]
939mod tests {
940    use super::*;
941    use crate::graph::ExportNamespace;
942
943    fn module_with_reference_paths(paths: &[Option<ReferencePathId>]) -> ModuleNode {
944        ModuleNode {
945            file_id: FileId(0),
946            path: PathBuf::from("/project/source.ts"),
947            edge_range: 0..0,
948            exports: vec![ExportSymbol {
949                name: ExportName::Named("value".to_string()),
950                is_type_only: false,
951                is_side_effect_used: false,
952                visibility: VisibilityTag::None,
953                expected_unused_reason: None,
954                span: oxc_span::Span::default(),
955                references: paths
956                    .iter()
957                    .map(|_| SymbolReference {
958                        from_file: FileId(0),
959                        kind: ReferenceKind::NamedImport,
960                        namespace: ExportNamespace::Value,
961                        import_span: oxc_span::Span::default(),
962                    })
963                    .collect(),
964                reference_paths: paths.to_vec(),
965                members: Vec::new(),
966                deprecated: false,
967                deprecated_reason: None,
968            }],
969            re_exports: Vec::new(),
970            flags: 0,
971        }
972    }
973
974    fn export_with_reference_files(files: &[u32]) -> ExportSymbol {
975        ExportSymbol {
976            name: ExportName::Named("value".to_string()),
977            is_type_only: false,
978            is_side_effect_used: false,
979            visibility: VisibilityTag::None,
980            expected_unused_reason: None,
981            span: oxc_span::Span::default(),
982            references: files
983                .iter()
984                .map(|file| SymbolReference {
985                    from_file: FileId(*file),
986                    kind: ReferenceKind::NamedImport,
987                    namespace: ExportNamespace::Value,
988                    import_span: oxc_span::Span::default(),
989                })
990                .collect(),
991            reference_paths: Vec::new(),
992            members: Vec::new(),
993            deprecated: false,
994            deprecated_reason: None,
995        }
996    }
997
998    #[test]
999    fn physical_references_deduplicate_small_and_large_sets() {
1000        let small = export_with_reference_files(&[0, 1, 2, 3, 4, 5, 6, 0]);
1001        let large = export_with_reference_files(&[0, 1, 2, 3, 4, 5, 6, 7, 0]);
1002
1003        assert_eq!(small.physical_references().count(), 7);
1004        assert_eq!(large.physical_references().count(), 8);
1005    }
1006
1007    #[test]
1008    fn reference_path_metadata_tracks_exact_depth_and_hop_bounds() {
1009        let mut interner = ReferencePathInterner::default();
1010        let root = interner
1011            .direct(FileId(10), ModuleLoadMechanism::EsModule)
1012            .expect("tracked interner must return a path");
1013        let lower = interner
1014            .extend(Some(root), FileId(5), ModuleLoadMechanism::EsModule)
1015            .expect("tracked interner must extend a path");
1016        let upper = interner
1017            .extend(Some(lower), FileId(20), ModuleLoadMechanism::EsModule)
1018            .expect("tracked interner must extend a path");
1019
1020        assert_eq!(interner.metadata[root.index()].depth, 0);
1021        assert_eq!(
1022            interner.metadata[lower.index()].hop_target_bounds,
1023            Some((FileId(5), FileId(10)))
1024        );
1025        assert_eq!(interner.metadata[upper.index()].depth, 2);
1026        assert_eq!(
1027            interner.metadata[upper.index()].hop_target_bounds,
1028            Some((FileId(5), FileId(20)))
1029        );
1030
1031        let repeated = interner.extend(Some(upper), FileId(10), ModuleLoadMechanism::EsModule);
1032        assert_eq!(repeated, Some(upper));
1033    }
1034
1035    #[test]
1036    fn finalized_reference_paths_are_independent_of_interning_order() {
1037        let mut first = ReferencePathInterner::default();
1038        let first_parent = first.direct(FileId(1), ModuleLoadMechanism::EsModule);
1039        let first_direct = first.direct(FileId(2), ModuleLoadMechanism::CommonJsRequire);
1040        let first_chain = first.extend(first_parent, FileId(3), ModuleLoadMechanism::EsModule);
1041        let mut first_modules = vec![module_with_reference_paths(&[first_direct, first_chain])];
1042        let first_nodes = first.finalize(&mut first_modules);
1043
1044        let mut second = ReferencePathInterner::default();
1045        let second_direct = second.direct(FileId(2), ModuleLoadMechanism::CommonJsRequire);
1046        let second_parent = second.direct(FileId(1), ModuleLoadMechanism::EsModule);
1047        let second_chain = second.extend(second_parent, FileId(3), ModuleLoadMechanism::EsModule);
1048        let mut second_modules = vec![module_with_reference_paths(&[second_direct, second_chain])];
1049        let second_nodes = second.finalize(&mut second_modules);
1050
1051        assert_eq!(first_nodes, second_nodes);
1052        assert_eq!(
1053            first_modules[0].exports[0].reference_paths,
1054            second_modules[0].exports[0].reference_paths
1055        );
1056    }
1057
1058    fn two_hop_route(first: FileId, second: FileId) -> ReferenceRouteGraphSpec {
1059        ReferenceRouteGraphSpec::new(vec![
1060            ReferenceRouteNodeSpec::new(
1061                first,
1062                ModuleLoadMechanism::EsModule,
1063                vec![ReferenceRouteNodeId(1)],
1064            ),
1065            ReferenceRouteNodeSpec::new(second, ModuleLoadMechanism::EsModule, Vec::new()),
1066        ])
1067    }
1068
1069    #[test]
1070    fn finalized_reference_routes_are_independent_of_interning_order() {
1071        let route_a = two_hop_route(FileId(1), FileId(2));
1072        let route_b = two_hop_route(FileId(3), FileId(4));
1073
1074        let mut first = ReferencePathInterner::default();
1075        let first_a = first.intern_route_graph(route_a.clone());
1076        let first_b = first.intern_route_graph(route_b.clone());
1077        let first_b_path = first.route(
1078            None,
1079            first_b,
1080            ReferenceRouteNodeId(0),
1081            ReferenceRouteNodeId(1),
1082            Some(ModuleLoadMechanism::CommonJsRequire),
1083        );
1084        let first_a_path = first.route(
1085            None,
1086            first_a,
1087            ReferenceRouteNodeId(0),
1088            ReferenceRouteNodeId(1),
1089            Some(ModuleLoadMechanism::EsModule),
1090        );
1091        let mut first_modules = vec![module_with_reference_paths(&[first_b_path, first_a_path])];
1092        let first_paths = first.finalize(&mut first_modules);
1093
1094        let mut second = ReferencePathInterner::default();
1095        let second_b = second.intern_route_graph(route_b);
1096        let second_a = second.intern_route_graph(route_a);
1097        let second_b_path = second.route(
1098            None,
1099            second_b,
1100            ReferenceRouteNodeId(0),
1101            ReferenceRouteNodeId(1),
1102            Some(ModuleLoadMechanism::CommonJsRequire),
1103        );
1104        let second_a_path = second.route(
1105            None,
1106            second_a,
1107            ReferenceRouteNodeId(0),
1108            ReferenceRouteNodeId(1),
1109            Some(ModuleLoadMechanism::EsModule),
1110        );
1111        let mut second_modules = vec![module_with_reference_paths(&[second_b_path, second_a_path])];
1112        let second_paths = second.finalize(&mut second_modules);
1113
1114        assert_eq!(first_paths, second_paths);
1115        assert_eq!(
1116            first_modules[0].exports[0].reference_paths,
1117            second_modules[0].exports[0].reference_paths
1118        );
1119    }
1120
1121    #[test]
1122    fn push_reference_without_paths_never_allocates_the_side_table() {
1123        let mut export = ExportSymbol {
1124            name: ExportName::Named("value".to_string()),
1125            is_type_only: false,
1126            is_side_effect_used: false,
1127            visibility: VisibilityTag::None,
1128            expected_unused_reason: None,
1129            span: oxc_span::Span::default(),
1130            references: Vec::new(),
1131            reference_paths: Vec::new(),
1132            members: Vec::new(),
1133            deprecated: false,
1134            deprecated_reason: None,
1135        };
1136        for id in 0..3 {
1137            export.push_reference(
1138                SymbolReference {
1139                    from_file: FileId(id),
1140                    kind: ReferenceKind::NamedImport,
1141                    namespace: ExportNamespace::Value,
1142                    import_span: oxc_span::Span::default(),
1143                },
1144                None,
1145            );
1146        }
1147        assert_eq!(export.references.len(), 3);
1148        assert!(export.reference_paths.is_empty());
1149        assert_eq!(export.reference_paths.capacity(), 0);
1150        assert_eq!(export.reference_path(1), None);
1151        assert!(export.has_reference_from(
1152            FileId(1),
1153            oxc_span::Span::default(),
1154            None,
1155            ExportNamespace::Value
1156        ));
1157        assert!(!export.has_reference_from(
1158            FileId(9),
1159            oxc_span::Span::default(),
1160            None,
1161            ExportNamespace::Value
1162        ));
1163    }
1164
1165    #[test]
1166    fn push_reference_backfills_the_side_table_on_the_first_tracked_path() {
1167        let mut export = ExportSymbol {
1168            name: ExportName::Named("value".to_string()),
1169            is_type_only: false,
1170            is_side_effect_used: false,
1171            visibility: VisibilityTag::None,
1172            expected_unused_reason: None,
1173            span: oxc_span::Span::default(),
1174            references: Vec::new(),
1175            reference_paths: Vec::new(),
1176            members: Vec::new(),
1177            deprecated: false,
1178            deprecated_reason: None,
1179        };
1180        let reference = SymbolReference {
1181            from_file: FileId(0),
1182            kind: ReferenceKind::NamedImport,
1183            namespace: ExportNamespace::Value,
1184            import_span: oxc_span::Span::default(),
1185        };
1186        export.push_reference(reference, None);
1187        let tracked = ReferencePathId::from_index(4);
1188        export.push_reference(reference, Some(tracked));
1189        export.push_reference(reference, None);
1190
1191        assert_eq!(export.reference_paths, vec![None, Some(tracked), None]);
1192        assert_eq!(export.reference_path(0), None);
1193        assert_eq!(export.reference_path(1), Some(tracked));
1194        assert!(export.has_reference_from(
1195            FileId(0),
1196            reference.import_span,
1197            Some(tracked),
1198            ExportNamespace::Value
1199        ));
1200        assert!(!export.has_reference_from(
1201            FileId(0),
1202            reference.import_span,
1203            Some(ReferencePathId::from_index(7)),
1204            ExportNamespace::Value
1205        ));
1206    }
1207
1208    #[test]
1209    fn module_node_construction() {
1210        let mut node = ModuleNode {
1211            file_id: FileId(0),
1212            path: PathBuf::from("/project/src/index.ts"),
1213            edge_range: 0..5,
1214            exports: vec![],
1215            re_exports: vec![],
1216            flags: ModuleNode::flags_from(true, true, false),
1217        };
1218        node.set_reachable(true);
1219        assert_eq!(node.file_id, FileId(0));
1220        assert!(node.is_entry_point());
1221        assert!(node.is_reachable());
1222        assert!(node.is_runtime_reachable());
1223        assert!(!node.is_test_reachable());
1224        assert!(!node.has_cjs_exports());
1225        assert_eq!(node.edge_range, 0..5);
1226    }
1227
1228    #[test]
1229    fn module_node_non_entry_unreachable() {
1230        let node = ModuleNode {
1231            file_id: FileId(5),
1232            path: PathBuf::from("/project/src/orphan.ts"),
1233            edge_range: 0..0,
1234            exports: vec![],
1235            re_exports: vec![],
1236            flags: ModuleNode::flags_from(false, false, false),
1237        };
1238        assert!(!node.is_entry_point());
1239        assert!(!node.is_reachable());
1240        assert!(!node.is_runtime_reachable());
1241        assert!(!node.is_test_reachable());
1242        assert!(node.edge_range.is_empty());
1243    }
1244
1245    #[test]
1246    fn module_node_cjs_exports() {
1247        let mut node = ModuleNode {
1248            file_id: FileId(2),
1249            path: PathBuf::from("/project/lib/legacy.js"),
1250            edge_range: 3..7,
1251            exports: vec![],
1252            re_exports: vec![],
1253            flags: ModuleNode::flags_from(false, true, true),
1254        };
1255        node.set_reachable(true);
1256        assert!(node.has_cjs_exports());
1257        assert!(node.is_runtime_reachable());
1258        assert_eq!(node.edge_range.len(), 4);
1259    }
1260
1261    #[test]
1262    fn module_node_with_exports_and_re_exports() {
1263        let node = ModuleNode {
1264            file_id: FileId(1),
1265            path: PathBuf::from("/project/src/barrel.ts"),
1266            edge_range: 0..3,
1267            exports: vec![ExportSymbol {
1268                name: ExportName::Named("localFn".to_string()),
1269                is_type_only: false,
1270                is_side_effect_used: false,
1271                visibility: VisibilityTag::None,
1272                expected_unused_reason: None,
1273                span: oxc_span::Span::new(0, 20),
1274                references: vec![],
1275                reference_paths: Vec::new(),
1276                members: vec![],
1277                deprecated: false,
1278                deprecated_reason: None,
1279            }],
1280            re_exports: vec![ReExportEdge {
1281                source_file: FileId(2),
1282                imported_name: "*".to_string(),
1283                exported_name: "*".to_string(),
1284                is_type_only: false,
1285                span: oxc_span::Span::default(),
1286            }],
1287            flags: ModuleNode::flags_from(false, true, false),
1288        };
1289        assert_eq!(node.exports.len(), 1);
1290        assert_eq!(node.re_exports.len(), 1);
1291        assert_eq!(node.re_exports[0].source_file, FileId(2));
1292    }
1293}