Skip to main content

fallow_graph/graph/
mod.rs

1//! Module dependency graph with re-export chain propagation and reachability analysis.
2//!
3//! The graph is built from resolved modules and entry points, then used to determine
4//! which files are reachable and which exports are referenced.
5
6mod ambiguity;
7mod build;
8mod cycles;
9mod effective_exports;
10mod effective_re_exports;
11mod entry_load;
12mod fan_io;
13mod impact_closure;
14mod namespace_aliases;
15mod namespace_indexes;
16mod namespace_re_exports;
17mod narrowing;
18mod package_entry;
19mod partition_order;
20mod public_exports;
21mod re_exports;
22mod reachability;
23mod shortest_import_path;
24mod test_reach;
25pub mod types;
26
27use std::path::Path;
28
29use fixedbitset::FixedBitSet;
30use rustc_hash::{FxHashMap, FxHashSet};
31
32use crate::resolve::{ResolvedModule, ResolvedReplacedModuleTarget};
33use fallow_types::discover::{DiscoveredFile, EntryPoint, FileId};
34use fallow_types::extract::{ImportLoadKind, ImportedName, ModuleLoadMechanism};
35use types::{ReferencePathInterner, ReferencePathNode, ReferenceRouteNodeId, ReferenceRoutes};
36
37/// Strip `root` and forward-slash-normalize a module path so report keys match
38/// across platforms. Every report surface that emits a root-relative path key
39/// goes through this, so the four surfaces cannot drift apart.
40pub(super) fn relativize(path: &Path, root: &Path) -> String {
41    path.strip_prefix(root)
42        .unwrap_or(path)
43        .to_string_lossy()
44        .replace('\\', "/")
45}
46
47pub use ambiguity::{AmbiguityParticipants, AmbiguousStarExport};
48pub use cycles::CycleOptions;
49pub use effective_exports::{EffectiveExportBinding, EffectiveExportResolution, ExportNamespace};
50pub use effective_re_exports::EffectiveReExportRoute;
51pub use entry_load::{DominatingImport, EntryLoadClosure};
52pub use fan_io::{FocusFileFacts, FocusFileFactsPaths};
53pub use impact_closure::{
54    CoordinationGap, CoordinationGapPaths, ImpactClosure, ImpactClosurePaths,
55};
56pub use partition_order::{PartitionOrder, PartitionOrderPaths, ReviewUnit, ReviewUnitPaths};
57pub use public_exports::PublicExportOrigin;
58pub use re_exports::GraphReExportCycle;
59pub use shortest_import_path::ImportPathHop;
60pub use test_reach::{ExportTestReach, ExportTestReachResult, TestMaskingState};
61pub use types::{
62    ExportSymbol, ModuleNode, ReExportEdge, ReferenceKind, ReferencePathId, SymbolReference,
63};
64
65/// Direct declaration selected by one unique effective export binding.
66#[derive(Debug, Clone, Copy)]
67pub struct EffectiveExportOrigin<'graph> {
68    file_id: FileId,
69    export: &'graph ExportSymbol,
70}
71
72/// One namespace-specific export exposed by a module and its effective binding.
73#[derive(Debug, Clone, Copy)]
74pub struct EffectiveExportSurface<'graph> {
75    binding: EffectiveExportBinding,
76    namespace: ExportNamespace,
77    export: Option<&'graph ExportSymbol>,
78    origin: Option<EffectiveExportOrigin<'graph>>,
79    local_export: bool,
80}
81
82impl<'graph> EffectiveExportSurface<'graph> {
83    /// Canonical binding exposed by the requested module/name/namespace.
84    #[must_use]
85    pub const fn binding(self) -> EffectiveExportBinding {
86        self.binding
87    }
88
89    /// Namespace selected for this surface.
90    #[must_use]
91    pub const fn namespace(self) -> ExportNamespace {
92        self.namespace
93    }
94
95    /// Reference-bearing graph export selected for this surface.
96    ///
97    /// Direct declarations use their origin export. Named re-exports use their
98    /// single barrel surface while references remain namespace-specific;
99    /// namespace objects and implicit SFC defaults have no declaration export.
100    #[must_use]
101    pub const fn export(self) -> Option<&'graph ExportSymbol> {
102        self.export
103    }
104
105    /// Direct declaration that owns this binding, when one exists.
106    #[must_use]
107    pub const fn origin(self) -> Option<EffectiveExportOrigin<'graph>> {
108        self.origin
109    }
110}
111
112impl<'graph> EffectiveExportOrigin<'graph> {
113    /// Module that owns the selected declaration.
114    #[must_use]
115    pub const fn file_id(self) -> FileId {
116        self.file_id
117    }
118
119    /// Selected declaration in its owning module.
120    #[must_use]
121    pub const fn export(self) -> &'graph ExportSymbol {
122        self.export
123    }
124}
125
126/// True when the path's final component looks like a TypeScript declaration
127/// file (`.d.ts`, `.d.mts`, `.d.cts`). Used to seed declaration files as
128/// overall entry points so ambient `typeof import()` references stay alive;
129/// see [`ModuleGraph::build_with_declaration_seeding`] for the exception.
130///
131/// The analysis layer reuses this predicate. Keep it in sync with the
132/// discovery walk's declaration-file predicate, which exempts declaration
133/// files from the file-size skip.
134#[must_use]
135pub fn is_declaration_file_path(path: &Path) -> bool {
136    path.file_name()
137        .and_then(|n| n.to_str())
138        .is_some_and(|name| {
139            name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
140        })
141}
142
143/// The entry points of a graph build, split by reachability role.
144#[derive(Debug, Clone, Copy)]
145pub struct ReachabilityRoots<'a> {
146    /// Every entry point, of any role.
147    pub all: &'a [EntryPoint],
148    /// Runtime/application entry points.
149    pub runtime: &'a [EntryPoint],
150    /// Test entry points.
151    pub test: &'a [EntryPoint],
152}
153
154/// The core module dependency graph.
155///
156/// Derives `serde` so the whole graph can be persisted to `.fallow/graph-cache.bin`
157/// (see `crate::cache`) and skipped on a re-run whose inputs are byte-identical.
158/// `namespace_imported` is a derived `FixedBitSet` reconstructed from the edge
159/// set on cache load (`reconstruct_namespace_imported`), so it is
160/// `#[serde(skip, default)]` rather than persisted.
161#[derive(Debug, serde::Serialize, serde::Deserialize)]
162pub struct ModuleGraph {
163    /// All modules indexed by `FileId`.
164    ///
165    /// Invariant: `modules[file_id.0 as usize].file_id == file_id` for every
166    /// `FileId` in the graph. Holds because `discover/walk.rs` assigns FileIds
167    /// sequentially via `.enumerate()` after path-sorting, and
168    /// `build::populate_edges` pushes one `ModuleNode` per file in iteration
169    /// order. Detectors rely on this for O(1) FileId-to-module lookup
170    /// (`graph.modules.get(file_id.0 as usize)`) instead of building a
171    /// per-call `FxHashMap<FileId, &ModuleNode>`.
172    pub modules: Vec<ModuleNode>,
173    /// Flat edge storage for cache-friendly iteration.
174    edges: Vec<Edge>,
175    /// Maps npm package names to the `FileId`s that import them, with one
176    /// entry per import. A file with two imports of a package appears twice.
177    pub package_usage: FxHashMap<String, Vec<FileId>>,
178    /// Maps npm package names to the `FileId`s that import them with type-only
179    /// imports, with one entry per type-only import.
180    /// A package appearing here but not in `package_usage` (or only in both) indicates
181    /// it's only used for types and could be a devDependency.
182    pub type_only_package_usage: FxHashMap<String, Vec<FileId>>,
183    /// Maps npm package names to the `FileId`s that read a file of the
184    /// package through a webpack asset loader (`raw-loader!pkg/file.txt`).
185    /// The bundle holds the text, the bytes or a URL of the file, so the
186    /// package is used at build time and the import is not a runtime import.
187    /// Every entry is also in `package_usage`.
188    pub asset_package_usage: FxHashMap<String, Vec<FileId>>,
189    /// Package specifiers that each module imports statically with a runtime
190    /// value (no `import()`, no type-only import). Read by the startup weight
191    /// report to list the packages on the startup path of an entry.
192    pub eager_package_imports: FxHashMap<FileId, Vec<EagerPackageImport>>,
193    /// All entry point `FileId`s.
194    pub entry_points: FxHashSet<FileId>,
195    /// Runtime/application entry point `FileId`s.
196    pub runtime_entry_points: FxHashSet<FileId>,
197    /// Test entry point `FileId`s.
198    pub test_entry_points: FxHashSet<FileId>,
199    /// Compact correlation index for distinct test-root replacement profiles.
200    ///
201    /// Empty when no test root declares a project-internal replacement. That
202    /// preserves the ordinary single-BFS test reachability path.
203    test_reachability_index: TestReachabilityIndex,
204    /// Flat interned linked paths used by exact export references.
205    reference_paths: Vec<ReferencePathNode>,
206    /// Compact transition graphs used by namespace-derived references.
207    reference_routes: ReferenceRoutes,
208    /// Reverse index: for each `FileId`, which files import it.
209    pub reverse_deps: Vec<Vec<FileId>>,
210    /// Precomputed: which modules have namespace imports (import * as ns).
211    ///
212    /// Derived entirely from the edge set (a module is namespace-imported iff
213    /// some edge to it carries an `ImportedName::Namespace` symbol), so it is
214    /// not persisted: on cache load it is rebuilt by
215    /// [`ModuleGraph::reconstruct_namespace_imported`], which replicates the
216    /// exact insertion logic from `build.rs`.
217    #[serde(skip, default)]
218    namespace_imported: FixedBitSet,
219    /// Re-export cycles and self-loops detected during Phase 4 chain
220    /// resolution. Each entry names the participating files (sorted
221    /// lexicographically) and a `is_self_loop` flag distinguishing
222    /// single-file self-re-exports from multi-node cycles. Populated by
223    /// `re_exports::find_re_export_cycles` and consumed by the analysis
224    /// backend, which wraps each entry in a typed `ReExportCycleFinding`.
225    pub re_export_cycles: Vec<GraphReExportCycle>,
226    /// Canonical direct and transitive export binding resolution.
227    effective_exports: effective_exports::EffectiveExportIndex,
228}
229
230/// An edge in the module graph.
231///
232/// Public consumers inspect relationships through summary methods such as
233/// [`ModuleGraph::direct_importer_summaries`] and
234/// [`ModuleGraph::outgoing_edge_summaries`]. Keeping the raw storage private
235/// preserves graph invariants and the `Edge == 32` size assertion below.
236#[derive(Debug, serde::Serialize, serde::Deserialize)]
237pub struct Edge {
238    /// Source module of this import edge.
239    source: FileId,
240    /// Target module imported by `source`.
241    target: FileId,
242    /// Symbols imported across this edge.
243    symbols: Vec<ImportedSymbol>,
244}
245
246impl Edge {
247    /// Whether the target can run as code through this edge (see
248    /// [`ImportLoadKind::runs_target_code`]). An edge whose every symbol is an
249    /// asset reference keeps the target in use, but reachability does not
250    /// continue into the imports of the target.
251    #[must_use]
252    pub(crate) fn runs_target_code(&self) -> bool {
253        self.symbols.is_empty()
254            || self
255                .symbols
256                .iter()
257                .any(|symbol| symbol.load_kind.runs_target_code())
258    }
259
260    /// Whether a test that loads the target through this edge runs target
261    /// code. The build erases a type-only import before the test runs, so an
262    /// edge whose every symbol is type-only gives no test reach. Dead-code
263    /// reachability keeps [`Self::runs_target_code`].
264    #[must_use]
265    pub(crate) fn runs_target_code_in_tests(&self) -> bool {
266        self.symbols.is_empty()
267            || self
268                .symbols
269                .iter()
270                .any(|symbol| !symbol.is_type_only && symbol.load_kind.runs_target_code())
271    }
272}
273
274/// One package specifier that a module imports statically with a runtime value.
275#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
276pub struct EagerPackageImport {
277    /// The package name, for example `lodash` or `@scope/pkg`.
278    pub package: String,
279    /// The specifier as written, for example `lodash/debounce`.
280    pub specifier: String,
281}
282
283/// A symbol imported across an edge.
284#[derive(Debug, serde::Serialize, serde::Deserialize)]
285pub struct ImportedSymbol {
286    /// The name as imported from the target (`Named`, `Default`, `Namespace`,
287    /// `SideEffect`).
288    pub imported_name: ImportedName,
289    /// Local binding name in the importing file.
290    pub local_name: String,
291    /// Byte span of the import statement in the source file.
292    #[serde(with = "crate::cache::span_serde")]
293    pub import_span: oxc_span::Span,
294    /// Whether this import is type-only (`import type { ... }`).
295    /// Used to skip type-only edges in circular dependency detection.
296    pub is_type_only: bool,
297    /// Whether the ambient star this symbol stands for is spelled
298    /// `export type *` (issue #2375), which forwards type meanings only.
299    pub is_type_only_star: bool,
300    /// Runtime module mechanism that created this symbol edge.
301    mechanism: ModuleLoadMechanism,
302    /// When the target loads relative to the importer, and whether it runs
303    /// at all. Fits in the padding after the flags, so the 64-byte size
304    /// assertion holds.
305    load_kind: ImportLoadKind,
306}
307
308impl ImportedSymbol {
309    /// When the target of this symbol edge loads, relative to the importer.
310    #[must_use]
311    pub const fn load_kind(&self) -> ImportLoadKind {
312        self.load_kind
313    }
314
315    /// Whether this symbol loads its target before the importer runs and
316    /// carries a runtime value, so the target is on the startup path.
317    #[must_use]
318    pub const fn is_eager_value(&self) -> bool {
319        self.load_kind.is_eager_value(self.is_type_only)
320    }
321
322    /// Whether this symbol runs its target (see
323    /// [`ImportLoadKind::loads_target`]). A path reference and an asset
324    /// reference keep the target in use without running it.
325    #[must_use]
326    pub const fn loads_target(&self) -> bool {
327        self.load_kind.loads_target()
328    }
329
330    /// Whether this symbol is the whole-module shape of `export *` or
331    /// `export * as ns` inside a `declare module '...'` body (issue #2357):
332    /// type-only, bound to no local name, and naming the module namespace or
333    /// its `default` member (recorded for the `export * as ns` form).
334    ///
335    /// The ambient body is erased at runtime, so package usage stays
336    /// type-only, but the star forwards every export of the target, so the
337    /// graph credits its star surface instead of narrowing to imported names.
338    /// Every other type-only symbol, bound (`import type { x }`) or not (an
339    /// ambient named re-export, an `import()` type reference), credits the
340    /// names it actually imports.
341    #[must_use]
342    pub(crate) fn is_ambient_star(&self) -> bool {
343        self.is_type_only
344            && self.local_name.is_empty()
345            && matches!(
346                self.imported_name,
347                ImportedName::Namespace | ImportedName::Default
348            )
349    }
350
351    /// Whether this ambient star forwards both meanings of every name it
352    /// carries.
353    ///
354    /// `export *` inside the body re-exports the target's value and type
355    /// declarations alike, so it credits both namespaces. `export type *`
356    /// erases every value meaning (issue #2375), so it credits type space
357    /// only, exactly like the ambient named re-exports of issue #2349.
358    #[must_use]
359    pub(crate) fn is_value_bearing_ambient_star(&self) -> bool {
360        self.is_ambient_star() && !self.is_type_only_star
361    }
362}
363
364/// Flat bitset index mapping files to the test profiles that reach them.
365///
366/// Each file owns `words_per_file` contiguous reachable-profile words. Masks are
367/// target-sparse: only explicit replacement targets own a row, while every row
368/// retains dense profile words for constant-time word lookup. Retained storage
369/// is `O((files + replaced_targets) * ceil(profiles / 64))`; correlation queries
370/// intersect machine words instead of scanning profile file lists.
371#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
372struct TestReachabilityIndex {
373    profile_count: usize,
374    words_per_file: usize,
375    reachable_profiles: Vec<u64>,
376    masked_profiles: Vec<MaskedTestProfiles>,
377    /// The test roots of each profile, by profile index. The per-root query
378    /// reads it to find the replacement mask of one root.
379    profile_roots: Vec<Vec<FileId>>,
380    /// True when test roots declare replacement masks, but the distinct mask
381    /// count passed the profile safety cap. Reachability then ignores the
382    /// masks, so a mocked module counts as reached.
383    masking_capped: bool,
384}
385
386/// Sparse profile-mask row for one replaced target.
387#[derive(Debug, serde::Serialize, serde::Deserialize)]
388struct MaskedTestProfiles {
389    target: FileId,
390    profiles: Vec<u64>,
391}
392
393impl TestReachabilityIndex {
394    fn new(file_capacity: usize, profile_count: usize) -> Self {
395        let words_per_file = profile_count.div_ceil(u64::BITS as usize);
396        let storage_len = file_capacity.saturating_mul(words_per_file);
397        Self {
398            profile_count,
399            words_per_file,
400            reachable_profiles: vec![0; storage_len],
401            masked_profiles: Vec::new(),
402            profile_roots: Vec::new(),
403            masking_capped: false,
404        }
405    }
406
407    /// The replaced targets of each test root that has a replacement, sorted
408    /// by target id.
409    fn root_masks(&self) -> FxHashMap<FileId, Vec<FileId>> {
410        let mut masks: FxHashMap<FileId, Vec<FileId>> = FxHashMap::default();
411        for row in &self.masked_profiles {
412            for (profile, roots) in self.profile_roots.iter().enumerate() {
413                let masked = row
414                    .profiles
415                    .get(profile / u64::BITS as usize)
416                    .is_some_and(|word| word & (1_u64 << (profile % u64::BITS as usize)) != 0);
417                if !masked {
418                    continue;
419                }
420                for &root in roots {
421                    masks.entry(root).or_default().push(row.target);
422                }
423            }
424        }
425        masks
426    }
427
428    fn set_sparse_masks(&mut self, masks: FxHashMap<FileId, Vec<u64>>) {
429        let mut rows: Vec<_> = masks
430            .into_iter()
431            .map(|(target, profiles)| MaskedTestProfiles { target, profiles })
432            .collect();
433        rows.sort_unstable_by_key(|row| row.target.0);
434        self.masked_profiles = rows;
435    }
436
437    fn profiles_for<'a>(&self, storage: &'a [u64], file_id: FileId) -> Option<&'a [u64]> {
438        let start = (file_id.0 as usize).checked_mul(self.words_per_file)?;
439        let end = start.checked_add(self.words_per_file)?;
440        storage.get(start..end)
441    }
442
443    fn masked_profiles_for(&self, file_id: FileId) -> Option<&[u64]> {
444        self.masked_profiles
445            .binary_search_by_key(&file_id.0, |row| row.target.0)
446            .ok()
447            .map(|index| self.masked_profiles[index].profiles.as_slice())
448    }
449
450    fn covers_reference_path(
451        &self,
452        source: FileId,
453        path: types::ReferencePathId,
454        paths: &[ReferencePathNode],
455        routes: &ReferenceRoutes,
456    ) -> bool {
457        let Some(source_profiles) = self.profiles_for(&self.reachable_profiles, source) else {
458            return false;
459        };
460
461        for (word_index, &source_word) in source_profiles.iter().enumerate() {
462            let mut active_profiles = source_word;
463            if active_profiles == 0 {
464                continue;
465            }
466
467            let mut next = Some(path);
468            while let Some(path_id) = next {
469                let Some(path_node) = paths.get(path_id.index()) else {
470                    return false;
471                };
472                next = path_node.parent();
473                active_profiles = match *path_node {
474                    ReferencePathNode::Hop {
475                        target, mechanism, ..
476                    } => self.active_hop_profiles(target, mechanism, word_index, active_profiles),
477                    ReferencePathNode::Route {
478                        graph,
479                        start,
480                        terminal,
481                        start_mechanism,
482                        ..
483                    } => self.active_route_profiles(
484                        routes,
485                        graph,
486                        start,
487                        terminal,
488                        start_mechanism,
489                        word_index,
490                        active_profiles,
491                    ),
492                };
493                if active_profiles == 0 {
494                    break;
495                }
496            }
497
498            if active_profiles != 0 {
499                return true;
500            }
501        }
502
503        false
504    }
505
506    fn active_hop_profiles(
507        &self,
508        target: FileId,
509        mechanism: ModuleLoadMechanism,
510        word_index: usize,
511        mut active_profiles: u64,
512    ) -> u64 {
513        let Some(target_word) = self
514            .profiles_for(&self.reachable_profiles, target)
515            .and_then(|profiles| profiles.get(word_index))
516        else {
517            return 0;
518        };
519        active_profiles &= target_word;
520        if matches!(mechanism, ModuleLoadMechanism::EsModule)
521            && let Some(masked_profiles) = self.masked_profiles_for(target)
522        {
523            let Some(masked_word) = masked_profiles.get(word_index) else {
524                return 0;
525            };
526            active_profiles &= !masked_word;
527        }
528        active_profiles
529    }
530
531    /// Evaluate one compact namespace transition graph with a monotone
532    /// profile-bit worklist. Each `(route node, profile bit)` is processed at
533    /// most once, including cyclic graphs.
534    #[expect(
535        clippy::too_many_arguments,
536        reason = "the route identity and profile word form one evaluation contract"
537    )]
538    fn active_route_profiles(
539        &self,
540        routes: &ReferenceRoutes,
541        graph_id: types::ReferenceRouteGraphId,
542        start: ReferenceRouteNodeId,
543        terminal: ReferenceRouteNodeId,
544        start_mechanism: Option<ModuleLoadMechanism>,
545        word_index: usize,
546        candidate_profiles: u64,
547    ) -> u64 {
548        let Some(graph) = routes.graphs.get(graph_id.0 as usize) else {
549            return 0;
550        };
551        let node_count = graph.nodes.end.saturating_sub(graph.nodes.start) as usize;
552        let start_index = start.0 as usize;
553        let terminal_index = terminal.0 as usize;
554        if start_index >= node_count || terminal_index >= node_count {
555            return 0;
556        }
557
558        let mut attempted = vec![0_u64; node_count];
559        let mut pending = vec![0_u64; node_count];
560        let mut queued = vec![false; node_count];
561        let mut queue = std::collections::VecDeque::from([start_index]);
562        pending[start_index] = candidate_profiles;
563        queued[start_index] = true;
564        let mut successful_profiles = 0_u64;
565
566        while let Some(local_index) = queue.pop_front() {
567            queued[local_index] = false;
568            let incoming = pending[local_index] & !attempted[local_index];
569            pending[local_index] = 0;
570            attempted[local_index] |= incoming;
571            if incoming == 0 {
572                continue;
573            }
574
575            let Some(node) = routes.nodes.get(graph.nodes.start as usize + local_index) else {
576                return 0;
577            };
578            let active = if local_index == start_index {
579                start_mechanism.map_or(incoming, |mechanism| {
580                    self.active_hop_profiles(node.target, mechanism, word_index, incoming)
581                })
582            } else {
583                self.active_hop_profiles(node.target, node.mechanism, word_index, incoming)
584            };
585            if active == 0 {
586                continue;
587            }
588            if local_index == terminal_index {
589                successful_profiles |= active;
590                continue;
591            }
592
593            let Some(successors) = routes
594                .edges
595                .get(node.successors.start as usize..node.successors.end as usize)
596            else {
597                return 0;
598            };
599            for successor in successors {
600                let successor_index = successor.0 as usize;
601                if successor_index >= node_count {
602                    return 0;
603                }
604                let new_profiles = active & !attempted[successor_index] & !pending[successor_index];
605                if new_profiles == 0 {
606                    continue;
607                }
608                pending[successor_index] |= new_profiles;
609                if !queued[successor_index] {
610                    queued[successor_index] = true;
611                    queue.push_back(successor_index);
612                }
613            }
614        }
615
616        successful_profiles
617    }
618
619    #[cfg(test)]
620    fn profile_contains(&self, storage: &[u64], file_id: FileId, profile: usize) -> bool {
621        self.profiles_for(storage, file_id)
622            .and_then(|words| words.get(profile / u64::BITS as usize))
623            .is_some_and(|word| word & (1_u64 << (profile % u64::BITS as usize)) != 0)
624    }
625
626    #[cfg(test)]
627    fn profile_reaches(&self, file_id: FileId, profile: usize) -> bool {
628        self.profile_contains(&self.reachable_profiles, file_id, profile)
629    }
630
631    #[cfg(test)]
632    fn profile_masks(&self, file_id: FileId, profile: usize) -> bool {
633        self.masked_profiles_for(file_id)
634            .and_then(|words| words.get(profile / u64::BITS as usize))
635            .is_some_and(|word| word & (1_u64 << (profile % u64::BITS as usize)) != 0)
636    }
637}
638
639/// One outgoing edge that runs its target, from
640/// [`ModuleGraph::outgoing_edge_summaries`].
641#[derive(Debug, Clone, Copy)]
642pub struct OutgoingEdgeSummary<'a> {
643    /// The imported module.
644    pub target: FileId,
645    /// Whether every symbol that loads the target is type-only, so the build
646    /// erases the import.
647    pub all_type_only: bool,
648    /// Byte offset of the first value symbol that loads the target, or of
649    /// the first loading symbol when all of them are type-only.
650    pub span_start: Option<u32>,
651    /// Every symbol of the edge, also the symbols that do not load the
652    /// target. Filter with [`ImportedSymbol::loads_target`].
653    pub symbols: &'a [ImportedSymbol],
654}
655
656/// Importer details for one file that directly imports a target module.
657#[derive(Debug, Clone, PartialEq, Eq)]
658pub struct DirectImporterSummary {
659    /// Source file that imports the requested target.
660    pub source: FileId,
661    /// Symbols imported from the target by this source file.
662    pub symbols: Vec<ImportedSymbolSummary>,
663}
664
665/// Symbol details for a direct import edge.
666#[derive(Debug, Clone, PartialEq, Eq)]
667pub struct ImportedSymbolSummary {
668    /// Imported binding name, using `default`, `*`, and `side-effect` for
669    /// non-named imports.
670    pub imported: String,
671    /// Local binding name in the importing file.
672    pub local: String,
673    /// Whether this symbol came from a type-only import.
674    pub type_only: bool,
675}
676
677#[cfg(target_pointer_width = "64")]
678const _: () = assert!(std::mem::size_of::<Edge>() == 32);
679#[cfg(target_pointer_width = "64")]
680const _: () = assert!(std::mem::size_of::<ImportedSymbol>() == 64);
681
682#[cold]
683#[inline(never)]
684fn propagate_namespace_references(
685    graph: &mut ModuleGraph,
686    module_by_id: &FxHashMap<FileId, &ResolvedModule>,
687    features: build::NamespaceFeatures,
688    exposed_namespace_targets: &re_exports::ExposedNamespaceTargets,
689    reference_paths: &mut ReferencePathInterner,
690) {
691    let indexes = namespace_indexes::NamespacePropagationIndexes::new(graph, module_by_id);
692    if features.has_aliases {
693        namespace_aliases::propagate_cross_package_aliases(
694            graph,
695            module_by_id,
696            &indexes,
697            reference_paths,
698        );
699    }
700    if features.has_re_exports {
701        namespace_re_exports::propagate_namespace_re_exports(
702            graph,
703            &indexes,
704            exposed_namespace_targets,
705            reference_paths,
706        );
707    }
708}
709
710impl ModuleGraph {
711    fn resolve_entry_point_ids(
712        entry_points: &[EntryPoint],
713        path_to_id: &FxHashMap<&Path, FileId>,
714    ) -> FxHashSet<FileId> {
715        entry_points
716            .iter()
717            .filter_map(|ep| {
718                path_to_id.get(ep.path.as_path()).copied().or_else(|| {
719                    dunce::canonicalize(&ep.path)
720                        .ok()
721                        .and_then(|path| path_to_id.get(path.as_path()).copied())
722                })
723            })
724            .collect()
725    }
726
727    /// Build the module graph from resolved modules and entry points.
728    pub fn build(
729        resolved_modules: &[ResolvedModule],
730        entry_points: &[EntryPoint],
731        files: &[DiscoveredFile],
732    ) -> Self {
733        Self::build_with_reachability_roots(
734            resolved_modules,
735            entry_points,
736            entry_points,
737            &[],
738            files,
739        )
740    }
741
742    /// Build the module graph with explicit runtime and test reachability roots.
743    pub fn build_with_reachability_roots(
744        resolved_modules: &[ResolvedModule],
745        entry_points: &[EntryPoint],
746        runtime_entry_points: &[EntryPoint],
747        test_entry_points: &[EntryPoint],
748        files: &[DiscoveredFile],
749    ) -> Self {
750        Self::build_with_reachability_roots_and_replacements(
751            resolved_modules,
752            &[],
753            entry_points,
754            runtime_entry_points,
755            test_entry_points,
756            files,
757        )
758    }
759
760    /// Build the module graph with root-specific test-time module replacements.
761    pub fn build_with_reachability_roots_and_replacements(
762        resolved_modules: &[ResolvedModule],
763        replaced_module_targets: &[ResolvedReplacedModuleTarget],
764        entry_points: &[EntryPoint],
765        runtime_entry_points: &[EntryPoint],
766        test_entry_points: &[EntryPoint],
767        files: &[DiscoveredFile],
768    ) -> Self {
769        Self::build_with_declaration_seeding(
770            resolved_modules,
771            replaced_module_targets,
772            &ReachabilityRoots {
773                all: entry_points,
774                runtime: runtime_entry_points,
775                test: test_entry_points,
776            },
777            files,
778            &FxHashSet::default(),
779        )
780    }
781
782    /// Build the module graph like
783    /// [`Self::build_with_reachability_roots_and_replacements`], except that
784    /// the declaration files in `unseeded_declaration_files` are not seeded as
785    /// entry points.
786    ///
787    /// Every other declaration file is seeded so ambient declarations and
788    /// their `typeof import()` references stay alive. The caller names the
789    /// orphan module declaration files: a module declaration file that nothing
790    /// points to adds nothing to the global scope, so it must be reachable
791    /// through an import to stay used.
792    pub fn build_with_declaration_seeding(
793        resolved_modules: &[ResolvedModule],
794        replaced_module_targets: &[ResolvedReplacedModuleTarget],
795        roots: &ReachabilityRoots<'_>,
796        files: &[DiscoveredFile],
797        unseeded_declaration_files: &FxHashSet<FileId>,
798    ) -> Self {
799        let ReachabilityRoots {
800            all: entry_points,
801            runtime: runtime_entry_points,
802            test: test_entry_points,
803        } = *roots;
804        let _span = tracing::info_span!("build_graph").entered();
805
806        let module_count = files.len();
807
808        let max_file_id = files
809            .iter()
810            .map(|f| f.id.0 as usize)
811            .max()
812            .map_or(0, |m| m + 1);
813        let total_capacity = max_file_id.max(module_count);
814
815        let path_to_id: FxHashMap<&Path, FileId> =
816            files.iter().map(|f| (f.path.as_path(), f.id)).collect();
817
818        let module_by_id: FxHashMap<FileId, &ResolvedModule> =
819            resolved_modules.iter().map(|m| (m.file_id, m)).collect();
820
821        let mut entry_point_ids = Self::resolve_entry_point_ids(entry_points, &path_to_id);
822        let runtime_entry_point_ids =
823            Self::resolve_entry_point_ids(runtime_entry_points, &path_to_id);
824        let test_entry_point_ids = Self::resolve_entry_point_ids(test_entry_points, &path_to_id);
825
826        for file in files {
827            if is_declaration_file_path(&file.path)
828                && !unseeded_declaration_files.contains(&file.id)
829            {
830                entry_point_ids.insert(file.id);
831            }
832        }
833
834        let (mut graph, namespace_features) = Self::populate_edges(&build::PopulateEdgesInput {
835            files,
836            module_by_id: &module_by_id,
837            entry_point_ids: &entry_point_ids,
838            runtime_entry_point_ids: &runtime_entry_point_ids,
839            test_entry_point_ids: &test_entry_point_ids,
840            module_count,
841            total_capacity,
842        });
843        graph.effective_exports = effective_exports::EffectiveExportIndex::build(resolved_modules);
844
845        let test_reachability_plan = reachability::TestReachabilityPlan::new(
846            &test_entry_point_ids,
847            replaced_module_targets,
848            total_capacity,
849        );
850
851        let mut reference_paths =
852            ReferencePathInterner::new(test_reachability_plan.requires_reference_provenance());
853        let whole_module_targets =
854            graph.populate_references(&module_by_id, &entry_point_ids, &mut reference_paths);
855        // Entry-point reachability depends on edges alone, so it is available
856        // here and is reused verbatim by `mark_reachable` below. The exposed
857        // namespace closure needs it to stay off modules the report already
858        // calls unused files.
859        let entry_reachable = graph.collect_reachable(&entry_point_ids, total_capacity);
860        let exposed_namespace_targets = graph.collect_exposed_namespace_targets(
861            &whole_module_targets,
862            &entry_reachable,
863            &module_by_id,
864        );
865
866        if namespace_features.has_aliases || namespace_features.has_re_exports {
867            propagate_namespace_references(
868                &mut graph,
869                &module_by_id,
870                namespace_features,
871                &exposed_namespace_targets,
872                &mut reference_paths,
873            );
874        }
875
876        graph.mark_reachable(
877            &entry_reachable,
878            &entry_point_ids,
879            &runtime_entry_point_ids,
880            test_reachability_plan,
881            total_capacity,
882        );
883
884        graph.re_export_cycles = graph.resolve_re_export_chains(
885            &module_by_id,
886            &exposed_namespace_targets,
887            &mut reference_paths,
888        );
889        let finalized_paths = reference_paths.finalize(&mut graph.modules);
890        graph.reference_paths = finalized_paths.paths;
891        graph.reference_routes = finalized_paths.routes;
892
893        graph
894    }
895
896    /// Total number of modules.
897    #[must_use]
898    pub const fn module_count(&self) -> usize {
899        self.modules.len()
900    }
901
902    /// Total number of edges.
903    #[must_use]
904    pub const fn edge_count(&self) -> usize {
905        self.edges.len()
906    }
907
908    /// Return whether any test-root traversal reaches `file_id`.
909    #[must_use]
910    pub fn is_test_reachable(&self, file_id: FileId) -> bool {
911        self.modules
912            .get(file_id.0 as usize)
913            .is_some_and(ModuleNode::is_test_reachable)
914    }
915
916    /// Return whether one test-root traversal covers the export reference at
917    /// `reference_index` on `export`.
918    ///
919    /// Coverage requires one profile that reaches the referencing file and
920    /// every target hop. ESM hops also require that profile not to replace the
921    /// hop target; CommonJS hops remain active because Vitest replacement mocks
922    /// do not intercept `require()`.
923    ///
924    /// Only a value reference can give coverage. A type reference never runs
925    /// the export, so it never gives coverage. This includes `import type` and
926    /// a plain import that the test uses only in type positions.
927    #[must_use]
928    pub fn is_test_reference_covered(&self, export: &ExportSymbol, reference_index: usize) -> bool {
929        let Some(reference) = export.references.get(reference_index) else {
930            return false;
931        };
932        if reference.namespace != ExportNamespace::Value {
933            return false;
934        }
935        if self.test_reachability_index.profile_count == 0 {
936            return self.is_test_reachable(reference.from_file);
937        }
938
939        let Some(path) = export.reference_path(reference_index) else {
940            return false;
941        };
942
943        self.test_reachability_index.covers_reference_path(
944            reference.from_file,
945            path,
946            &self.reference_paths,
947            &self.reference_routes,
948        )
949    }
950
951    /// Return whether any reference on `export` is covered by a test-root
952    /// traversal.
953    #[must_use]
954    pub fn is_any_test_reference_covered(&self, export: &ExportSymbol) -> bool {
955        (0..export.references.len())
956            .any(|reference_index| self.is_test_reference_covered(export, reference_index))
957    }
958
959    #[cfg(test)]
960    fn reference_path_hops(
961        &self,
962        export: &ExportSymbol,
963        reference_index: usize,
964    ) -> Vec<(FileId, ModuleLoadMechanism)> {
965        let mut hops = Vec::new();
966        let mut next = export.reference_path(reference_index);
967        while let Some(path_id) = next {
968            let Some(node) = self.reference_paths.get(path_id.index()) else {
969                return Vec::new();
970            };
971            next = node.parent();
972            match *node {
973                ReferencePathNode::Hop {
974                    target, mechanism, ..
975                } => hops.push((target, mechanism)),
976                ReferencePathNode::Route {
977                    graph,
978                    start,
979                    terminal,
980                    start_mechanism,
981                    ..
982                } => hops.extend(self.reference_routes.canonical_hops(
983                    graph,
984                    start,
985                    terminal,
986                    start_mechanism,
987                )),
988            }
989        }
990        hops
991    }
992
993    /// Rebuild the `namespace_imported` bitset from the edge set.
994    ///
995    /// `namespace_imported` is `#[serde(skip)]`, so a graph loaded from the
996    /// persisted cache (`crate::cache`) arrives with an empty default bitset.
997    /// This restores it by replicating the EXACT insertion rule from
998    /// `build.rs`: a target `FileId` is namespace-imported iff some edge to it
999    /// carries an `ImportedName::Namespace` symbol. Both build-time insertion
1000    /// sites (static / dynamic `import * as ns` in `collect_import_edge`, and
1001    /// glob dynamic-import patterns in `collect_edges_for_module`) push a
1002    /// `Namespace` symbol onto the target's edge, so iterating the persisted
1003    /// edges and checking for a `Namespace` symbol reproduces the original
1004    /// bitset bit-for-bit. The capacity matches `build.rs`'s
1005    /// `max_file_id.max(module_count)`, which equals `modules.len()` under the
1006    /// dense path-sorted FileId invariant.
1007    pub(crate) fn reconstruct_namespace_imported(&mut self) {
1008        let capacity = self
1009            .edges
1010            .iter()
1011            .map(|edge| edge.target.0 as usize + 1)
1012            .max()
1013            .unwrap_or(0)
1014            .max(self.modules.len());
1015        let mut bitset = FixedBitSet::with_capacity(capacity);
1016        for edge in &self.edges {
1017            if edge
1018                .symbols
1019                .iter()
1020                .any(|sym| matches!(sym.imported_name, ImportedName::Namespace))
1021            {
1022                let idx = edge.target.0 as usize;
1023                if idx < capacity {
1024                    bitset.insert(idx);
1025                }
1026            }
1027        }
1028        self.namespace_imported = bitset;
1029    }
1030
1031    /// Resolve the effective declaration exported under `name` in one namespace.
1032    ///
1033    /// This is the canonical graph contract for direct exports and every named
1034    /// or star re-export path. Missing and ambiguous bindings are explicit so
1035    /// consumers cannot accidentally credit an arbitrary source declaration.
1036    #[must_use]
1037    pub fn resolve_export(
1038        &self,
1039        file_id: FileId,
1040        name: &str,
1041        namespace: ExportNamespace,
1042    ) -> EffectiveExportResolution {
1043        self.effective_exports.resolve(file_id, name, namespace)
1044    }
1045
1046    /// Whether two effective bindings denote the same declaration surface.
1047    ///
1048    /// TypeScript declaration merges occupy separate export slots while
1049    /// representing one symbol. Consumers that compare type and value lanes
1050    /// use this instead of raw binding equality so either half can carry the
1051    /// reference credit for the merged declaration.
1052    #[must_use]
1053    pub fn effective_bindings_share_declaration_group(
1054        &self,
1055        left: EffectiveExportBinding,
1056        right: EffectiveExportBinding,
1057    ) -> bool {
1058        if left == right || left.origin_file() != right.origin_file() {
1059            return left == right;
1060        }
1061        let Some(right_slot) = right.origin_slot() else {
1062            return false;
1063        };
1064        self.effective_exports
1065            .declaration_group_slots(left)
1066            .contains(&right_slot)
1067    }
1068
1069    /// Resolve one exported name to its unique direct declaration.
1070    ///
1071    /// Missing and ambiguous bindings return `None`. Namespace-object exports
1072    /// are bindings in their own right rather than direct declarations, so
1073    /// they also have no declaration origin.
1074    #[must_use]
1075    pub fn resolve_export_origin(
1076        &self,
1077        file_id: FileId,
1078        name: &str,
1079        namespace: ExportNamespace,
1080    ) -> Option<EffectiveExportOrigin<'_>> {
1081        let EffectiveExportResolution::Unique(binding) =
1082            self.resolve_export(file_id, name, namespace)
1083        else {
1084            return None;
1085        };
1086        self.export_binding_origin(binding)
1087    }
1088
1089    /// Resolve one module surface to its canonical binding and reference-bearing
1090    /// graph export. Value and type namespaces are selected independently.
1091    #[must_use]
1092    pub fn effective_export_surface(
1093        &self,
1094        file_id: FileId,
1095        name: &str,
1096        namespace: ExportNamespace,
1097    ) -> Option<EffectiveExportSurface<'_>> {
1098        let EffectiveExportResolution::Unique(binding) =
1099            self.resolve_export(file_id, name, namespace)
1100        else {
1101            return None;
1102        };
1103        let module = self.modules.get(file_id.0 as usize)?;
1104        let exact_surface = module.exports.iter().find(|export| {
1105            export.name.matches_str(name)
1106                && match namespace {
1107                    ExportNamespace::Type => export.is_type_only,
1108                    ExportNamespace::Value => !export.is_type_only,
1109                }
1110        });
1111        let surface_export = exact_surface.or_else(|| {
1112            module
1113                .exports
1114                .iter()
1115                .find(|export| export.name.matches_str(name))
1116        });
1117        let origin = self.export_binding_origin(binding);
1118        let export = surface_export.or_else(|| origin.map(|o| o.export));
1119        Some(EffectiveExportSurface {
1120            binding,
1121            namespace,
1122            export,
1123            origin,
1124            local_export: surface_export.is_some(),
1125        })
1126    }
1127
1128    /// Local re-export specifier that owns one effective module surface.
1129    ///
1130    /// Direct declarations and star-only forwarded surfaces return `None`.
1131    /// Named and namespace re-exports return the namespace-compatible edge
1132    /// whose source resolves to the same canonical binding.
1133    #[must_use]
1134    pub fn effective_export_surface_re_export(
1135        &self,
1136        file_id: FileId,
1137        name: &str,
1138        namespace: ExportNamespace,
1139    ) -> Option<&ReExportEdge> {
1140        let EffectiveExportResolution::Unique(binding) =
1141            self.resolve_export(file_id, name, namespace)
1142        else {
1143            return None;
1144        };
1145        self.modules
1146            .get(file_id.0 as usize)?
1147            .re_exports
1148            .iter()
1149            .find(|re_export| {
1150                re_export.exported_name == name
1151                    && (namespace == ExportNamespace::Type || !re_export.is_type_only)
1152                    && if re_export.imported_name == "*" {
1153                        binding.namespace_source() == Some(re_export.source_file)
1154                    } else {
1155                        self.resolve_export(
1156                            re_export.source_file,
1157                            &re_export.imported_name,
1158                            namespace,
1159                        ) == EffectiveExportResolution::Unique(binding)
1160                    }
1161            })
1162    }
1163
1164    /// References that reach one exact module export surface.
1165    ///
1166    /// Star-only surfaces share their declaration with other barrels, so their
1167    /// origin references are filtered by recorded provenance instead of being
1168    /// borrowed wholesale from the declaration.
1169    #[must_use]
1170    pub fn effective_export_surface_references(
1171        &self,
1172        file_id: FileId,
1173        name: &str,
1174        namespace: ExportNamespace,
1175    ) -> Vec<&SymbolReference> {
1176        let Some(surface) = self.effective_export_surface(file_id, name, namespace) else {
1177            return Vec::new();
1178        };
1179        let Some(export) = surface.export() else {
1180            return Vec::new();
1181        };
1182        if surface.local_export
1183            || surface
1184                .origin()
1185                .is_none_or(|origin| origin.file_id() == file_id)
1186        {
1187            return export.references_in(namespace).collect();
1188        }
1189        let mut exposed: FxHashMap<FileId, FxHashSet<String>> = FxHashMap::default();
1190        exposed.entry(file_id).or_default().insert(name.to_string());
1191        for route in self.effective_re_export_routes(file_id, name, namespace) {
1192            exposed
1193                .entry(route.barrel_file())
1194                .or_default()
1195                .insert(route.exported_name().to_string());
1196        }
1197        export
1198            .references
1199            .iter()
1200            .filter(|reference| {
1201                reference.namespace == namespace
1202                    && self.reference_reaches_surface(reference, &exposed, namespace)
1203            })
1204            .collect()
1205    }
1206
1207    fn reference_reaches_surface(
1208        &self,
1209        reference: &SymbolReference,
1210        exposed: &FxHashMap<FileId, FxHashSet<String>>,
1211        namespace: ExportNamespace,
1212    ) -> bool {
1213        if reference.kind == ReferenceKind::ReExport && exposed.contains_key(&reference.from_file) {
1214            return true;
1215        }
1216        self.outgoing_symbol_edges(reference.from_file)
1217            .any(|(target, symbols)| {
1218                let Some(names) = exposed.get(&target) else {
1219                    return false;
1220                };
1221                symbols.iter().any(|symbol| {
1222                    symbol.import_span == reference.import_span
1223                        && (namespace == ExportNamespace::Type
1224                            || !symbol.is_type_only
1225                            || symbol.is_value_bearing_ambient_star())
1226                        && match &symbol.imported_name {
1227                            ImportedName::Named(imported) => names.contains(imported.as_str()),
1228                            ImportedName::Default => names.contains("default"),
1229                            ImportedName::Namespace => true,
1230                            ImportedName::SideEffect => false,
1231                        }
1232                })
1233            })
1234    }
1235
1236    /// Resolve a unique binding to its direct declaration, when it has one.
1237    #[must_use]
1238    pub fn export_binding_origin(
1239        &self,
1240        binding: EffectiveExportBinding,
1241    ) -> Option<EffectiveExportOrigin<'_>> {
1242        let origin_file = binding.origin_file();
1243        let export = self
1244            .modules
1245            .get(origin_file.0 as usize)?
1246            .exports
1247            .get(binding.origin_slot()?)?;
1248        Some(EffectiveExportOrigin {
1249            file_id: origin_file,
1250            export,
1251        })
1252    }
1253
1254    /// Unique bindings exposed by a module in one namespace.
1255    ///
1256    /// Multiple names that resolve to the same declaration are deduplicated;
1257    /// missing and ambiguous exports are excluded.
1258    #[must_use]
1259    pub fn unique_export_bindings(
1260        &self,
1261        file_id: FileId,
1262        namespace: ExportNamespace,
1263    ) -> FxHashSet<EffectiveExportBinding> {
1264        self.effective_exports.unique_bindings(file_id, namespace)
1265    }
1266
1267    /// Whether `importer` connects to `source` as an origin of `name`.
1268    ///
1269    /// Any direct import connects the two modules for duplicate-export
1270    /// grouping, even when it imports a different symbol. A re-export-only edge
1271    /// connects them only when it contributes this binding, including each
1272    /// contributor to an ambiguous star export and excluding star bindings
1273    /// shadowed by an explicit export.
1274    #[must_use]
1275    pub fn importer_connects_export_origin(
1276        &self,
1277        importer: FileId,
1278        source: FileId,
1279        name: &str,
1280        namespace: ExportNamespace,
1281    ) -> bool {
1282        let Some(importer_module) = self.modules.get(importer.0 as usize) else {
1283            return false;
1284        };
1285        let re_export_count = importer_module
1286            .re_exports
1287            .iter()
1288            .filter(|re_export| re_export.source_file == source)
1289            .count();
1290        if self.edges[importer_module.edge_range.clone()]
1291            .iter()
1292            .any(|edge| edge.target == source && edge.symbols.len() > re_export_count)
1293        {
1294            return true;
1295        }
1296
1297        importer_module.re_exports.iter().any(|re_export| {
1298            if re_export.source_file != source
1299                || (namespace == ExportNamespace::Value && re_export.is_type_only)
1300            {
1301                return false;
1302            }
1303            let exported_name = if re_export.imported_name == "*" {
1304                if re_export.exported_name != "*" || name == "default" {
1305                    return false;
1306                }
1307                name
1308            } else {
1309                if re_export.imported_name != name {
1310                    return false;
1311                }
1312                &re_export.exported_name
1313            };
1314            self.effective_exports.contributes_through(
1315                importer,
1316                exported_name,
1317                source,
1318                name,
1319                namespace,
1320            )
1321        })
1322    }
1323
1324    /// Check if any importer uses `import * as ns` for this module.
1325    /// Uses precomputed bitset, O(1) lookup.
1326    #[must_use]
1327    pub fn has_namespace_import(&self, file_id: FileId) -> bool {
1328        let idx = file_id.0 as usize;
1329        if idx >= self.namespace_imported.len() {
1330            return false;
1331        }
1332        self.namespace_imported.contains(idx)
1333    }
1334
1335    /// Get the target `FileId`s of all outgoing edges for a module.
1336    #[must_use]
1337    pub fn edges_for(&self, file_id: FileId) -> Vec<FileId> {
1338        let idx = file_id.0 as usize;
1339        if idx >= self.modules.len() {
1340            return Vec::new();
1341        }
1342        let range = &self.modules[idx].edge_range;
1343        self.edges[range.clone()].iter().map(|e| e.target).collect()
1344    }
1345
1346    /// Iterate the outgoing edges of `file_id` with full per-symbol data.
1347    ///
1348    /// `fallow trace` needs the raw `ImportedSymbol` set on each edge in
1349    /// both directions, which the flattened summary structs cannot express.
1350    /// Returns an empty iterator for out-of-range file ids.
1351    pub fn outgoing_symbol_edges(
1352        &self,
1353        file_id: FileId,
1354    ) -> impl Iterator<Item = (FileId, &[ImportedSymbol])> + '_ {
1355        let idx = file_id.0 as usize;
1356        let range = if idx < self.modules.len() {
1357            self.modules[idx].edge_range.clone()
1358        } else {
1359            0..0
1360        };
1361        self.edges[range]
1362            .iter()
1363            .map(|edge| (edge.target, edge.symbols.as_slice()))
1364    }
1365
1366    /// The importer `FileId`s that directly import `target` (reverse-dep view).
1367    ///
1368    /// Returns an empty slice when `target` is out of range.
1369    #[must_use]
1370    pub fn importers_of(&self, target: FileId) -> &[FileId] {
1371        self.reverse_deps
1372            .get(target.0 as usize)
1373            .map_or(&[], Vec::as_slice)
1374    }
1375
1376    /// Summarize files that directly import `target`.
1377    ///
1378    /// Uses existing reverse dependency and edge indexes. Returns an empty
1379    /// list when the target is out of range or has no importers.
1380    #[must_use]
1381    pub fn direct_importer_summaries(&self, target: FileId) -> Vec<DirectImporterSummary> {
1382        let Some(importers) = self.reverse_deps.get(target.0 as usize) else {
1383            return Vec::new();
1384        };
1385
1386        let mut summaries = Vec::new();
1387        for &source in importers {
1388            let idx = source.0 as usize;
1389            let Some(source_node) = self.modules.get(idx) else {
1390                continue;
1391            };
1392            let mut symbols = Vec::new();
1393            for edge in &self.edges[source_node.edge_range.clone()] {
1394                if edge.target != target {
1395                    continue;
1396                }
1397                symbols.extend(edge.symbols.iter().map(|symbol| ImportedSymbolSummary {
1398                    imported: imported_name_label(&symbol.imported_name),
1399                    local: symbol.local_name.clone(),
1400                    type_only: symbol.is_type_only,
1401                }));
1402            }
1403            symbols.sort_by(|a, b| {
1404                a.imported
1405                    .cmp(&b.imported)
1406                    .then_with(|| a.local.cmp(&b.local))
1407                    .then_with(|| a.type_only.cmp(&b.type_only))
1408            });
1409            symbols.dedup();
1410            summaries.push(DirectImporterSummary { source, symbols });
1411        }
1412        summaries.sort_by_key(|summary| summary.source.0);
1413        summaries
1414    }
1415
1416    /// Find the byte offset of the import statement from `source` to `target`.
1417    ///
1418    /// Mixed imports to the same target are stored as one edge. Prefer the
1419    /// first eager value import, then the first value-carrying import, so
1420    /// runtime-cycle diagnostics and line suppressions anchor on the import
1421    /// that actually participates in the cycle. With lazy edges skipped, a
1422    /// lazy `import()` on a mixed edge is not part of the cycle.
1423    /// Returns `None` if no edge exists or the edge has no symbols.
1424    #[must_use]
1425    pub fn find_import_span_start(&self, source: FileId, target: FileId) -> Option<u32> {
1426        let idx = source.0 as usize;
1427        if idx >= self.modules.len() {
1428            return None;
1429        }
1430        let range = &self.modules[idx].edge_range;
1431        for edge in &self.edges[range.clone()] {
1432            if edge.target == target {
1433                return edge
1434                    .symbols
1435                    .iter()
1436                    .find(|s| s.is_eager_value())
1437                    .or_else(|| edge.symbols.iter().find(|s| !s.is_type_only))
1438                    .or_else(|| edge.symbols.first())
1439                    .map(|s| s.import_span.start);
1440            }
1441        }
1442        None
1443    }
1444
1445    /// Iterate the outgoing edges of `file_id` that run their target, with
1446    /// the data the boundary detector and the security scans need in a single
1447    /// pass.
1448    ///
1449    /// Only symbols that load the target count (see
1450    /// [`ImportedSymbol::loads_target`]). A `require.resolve('./x')` path
1451    /// reference and an asset loader request (`raw-loader!./x.js`) keep the
1452    /// target in use but do not run it, so they cannot cross an architecture
1453    /// boundary or carry code into a bundle. An edge without such a symbol is
1454    /// skipped.
1455    ///
1456    /// When `featureB` has both `import type { Foo } from './x'` and
1457    /// `import { bar } from './x'`, fallow groups them into ONE edge with the
1458    /// type-only symbol first and the value symbol second. The summary
1459    /// anchors on the value symbol, so findings anchor on the runtime import
1460    /// line; otherwise a `// fallow-ignore-next-line` above the type-only line
1461    /// would silently suppress the real violation.
1462    ///
1463    /// Returns an empty iterator for out-of-range file ids.
1464    pub fn outgoing_edge_summaries(
1465        &self,
1466        file_id: FileId,
1467    ) -> impl Iterator<Item = OutgoingEdgeSummary<'_>> + '_ {
1468        self.outgoing_symbol_edges(file_id)
1469            .filter_map(|(target, symbols)| {
1470                let mut loading = symbols.iter().filter(|s| s.loads_target()).peekable();
1471                let first = loading.peek().copied()?;
1472                let value = loading.find(|s| !s.is_type_only);
1473                Some(OutgoingEdgeSummary {
1474                    target,
1475                    all_type_only: value.is_none(),
1476                    span_start: Some(value.unwrap_or(first).import_span.start),
1477                    symbols,
1478                })
1479            })
1480    }
1481
1482    /// Iterate outgoing edges with the symbols of each edge.
1483    ///
1484    /// One edge holds every import from `file_id` to one target, so
1485    /// `import type { Y } from './y'` and `import { y } from './y'` share an
1486    /// edge. Use this method when a consumer must suppress or report each
1487    /// import statement on its own line; [`Self::outgoing_edge_summaries`]
1488    /// gives only one span per edge. A re-export or a dynamic import pattern
1489    /// has an empty `import_span`.
1490    ///
1491    /// Returns an empty iterator for out-of-range file ids.
1492    pub fn outgoing_edge_symbols(
1493        &self,
1494        file_id: FileId,
1495    ) -> impl Iterator<Item = (FileId, &[ImportedSymbol])> + '_ {
1496        let idx = file_id.0 as usize;
1497        let range = if idx < self.modules.len() {
1498            self.modules[idx].edge_range.clone()
1499        } else {
1500            0..0
1501        };
1502        self.edges[range]
1503            .iter()
1504            .map(|edge| (edge.target, edge.symbols.as_slice()))
1505    }
1506
1507    /// Like [`Self::outgoing_edge_summaries`] (only edges that run their
1508    /// target, and only their loading symbols) but additionally reports, as a
1509    /// fourth boolean, whether EVERY non-type-only symbol on the edge has an
1510    /// `import_span` start in `excluded_span_starts` (`all_client_only`). The
1511    /// security `client-server-leak` BFS passes the `next/dynamic ssr:false`
1512    /// dynamic-import span starts so it can skip an edge reached ONLY through the
1513    /// client-only escape hatch. An edge with no non-type-only symbols, or with at
1514    /// least one non-type-only symbol whose span is not excluded, reports `false`
1515    /// (so a target also reached via a real static import stays in the cone).
1516    ///
1517    /// Unlike [`Self::outgoing_edge_summaries`], a named symbol also counts as
1518    /// type-only when the name resolves on the target to a type declaration and
1519    /// to no value declaration (`import { Props } from "./x"` where `x` has
1520    /// `export interface Props`). The build erases such an import, so it cannot
1521    /// leak into a client bundle. Default, namespace, and side-effect imports,
1522    /// and names that do not resolve to a type, stay live.
1523    ///
1524    /// Returns an empty iterator for out-of-range file ids.
1525    pub fn outgoing_edge_summaries_with_exclusions<'a>(
1526        &'a self,
1527        file_id: FileId,
1528        excluded_span_starts: &'a FxHashSet<u32>,
1529    ) -> impl Iterator<Item = (FileId, bool, Option<u32>, bool)> + 'a {
1530        let idx = file_id.0 as usize;
1531        let range = if idx < self.modules.len() {
1532            self.modules[idx].edge_range.clone()
1533        } else {
1534            0..0
1535        };
1536        self.edges[range].iter().filter_map(move |edge| {
1537            let loading = || edge.symbols.iter().filter(|s| s.loads_target());
1538            let first = loading().next()?;
1539            let erased = |s: &&ImportedSymbol| {
1540                s.is_type_only || self.names_only_type_exports(edge.target, &s.imported_name)
1541            };
1542            let all_type_only = loading().all(|s| erased(&s));
1543            let span = loading()
1544                .find(|s| !erased(s))
1545                .unwrap_or(first)
1546                .import_span
1547                .start;
1548            // `all_client_only`: there is at least one non-type-only symbol and
1549            // every such symbol's import span is in the excluded set. A
1550            // non-excluded value symbol keeps the edge live.
1551            let mut value_symbols = loading().filter(|s| !erased(s)).peekable();
1552            let all_client_only = value_symbols.peek().is_some()
1553                && value_symbols.all(|s| excluded_span_starts.contains(&s.import_span.start));
1554            Some((edge.target, all_type_only, Some(span), all_client_only))
1555        })
1556    }
1557}
1558
1559impl ModuleGraph {
1560    /// Return `true` when `name` is a named import that resolves on `target`
1561    /// to a type declaration and to no value declaration. Default, namespace,
1562    /// and side-effect imports return `false`, and so does a name that the
1563    /// graph cannot resolve in the type namespace.
1564    fn names_only_type_exports(&self, target: FileId, name: &ImportedName) -> bool {
1565        let ImportedName::Named(name) = name else {
1566            return false;
1567        };
1568        matches!(
1569            self.resolve_export(target, name, ExportNamespace::Value),
1570            EffectiveExportResolution::Missing
1571        ) && matches!(
1572            self.resolve_export(target, name, ExportNamespace::Type),
1573            EffectiveExportResolution::Unique(_)
1574        )
1575    }
1576}
1577
1578fn imported_name_label(name: &ImportedName) -> String {
1579    match name {
1580        ImportedName::Named(name) => name.clone(),
1581        ImportedName::Default => "default".to_string(),
1582        ImportedName::Namespace => "*".to_string(),
1583        ImportedName::SideEffect => "side-effect".to_string(),
1584    }
1585}
1586
1587#[cfg(test)]
1588mod tests {
1589    use super::*;
1590    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
1591    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1592    use fallow_types::extract::{ExportName, ImportInfo, ImportedName, VisibilityTag};
1593    use std::path::PathBuf;
1594
1595    fn build_simple_graph() -> ModuleGraph {
1596        let files = vec![
1597            DiscoveredFile {
1598                id: FileId(0),
1599                path: PathBuf::from("/project/src/entry.ts"),
1600                size_bytes: 100,
1601            },
1602            DiscoveredFile {
1603                id: FileId(1),
1604                path: PathBuf::from("/project/src/utils.ts"),
1605                size_bytes: 50,
1606            },
1607        ];
1608
1609        let entry_points = vec![EntryPoint {
1610            path: PathBuf::from("/project/src/entry.ts"),
1611            source: EntryPointSource::PackageJsonMain,
1612        }];
1613
1614        let resolved_modules = vec![
1615            ResolvedModule {
1616                file_id: FileId(0),
1617                path: PathBuf::from("/project/src/entry.ts"),
1618                resolved_imports: vec![ResolvedImport {
1619                    info: ImportInfo {
1620                        source: "./utils".to_string(),
1621                        imported_name: ImportedName::Named("foo".to_string()),
1622                        local_name: "foo".to_string(),
1623                        is_type_only: false,
1624                        is_type_only_star: false,
1625                        from_style: false,
1626                        span: oxc_span::Span::new(0, 10),
1627                        source_span: oxc_span::Span::default(),
1628                    },
1629                    target: ResolveResult::InternalModule(FileId(1)),
1630                }],
1631                ..Default::default()
1632            },
1633            ResolvedModule {
1634                file_id: FileId(1),
1635                path: PathBuf::from("/project/src/utils.ts"),
1636                exports: vec![
1637                    fallow_types::extract::ExportInfo {
1638                        name: ExportName::Named("foo".to_string()),
1639                        local_name: Some("foo".to_string()),
1640                        is_type_only: false,
1641                        visibility: VisibilityTag::None,
1642                        expected_unused_reason: None,
1643                        span: oxc_span::Span::new(0, 20),
1644                        members: vec![],
1645                        is_side_effect_used: false,
1646                        super_class: None,
1647                        deprecated: false,
1648                        deprecated_reason: None,
1649                    },
1650                    fallow_types::extract::ExportInfo {
1651                        name: ExportName::Named("bar".to_string()),
1652                        local_name: Some("bar".to_string()),
1653                        is_type_only: false,
1654                        visibility: VisibilityTag::None,
1655                        expected_unused_reason: None,
1656                        span: oxc_span::Span::new(25, 45),
1657                        members: vec![],
1658                        is_side_effect_used: false,
1659                        super_class: None,
1660                        deprecated: false,
1661                        deprecated_reason: None,
1662                    },
1663                ]
1664                .into(),
1665                ..Default::default()
1666            },
1667        ];
1668
1669        ModuleGraph::build(&resolved_modules, &entry_points, &files)
1670    }
1671
1672    fn declaration_seeding_graph(unseeded: &FxHashSet<FileId>) -> ModuleGraph {
1673        let paths = [
1674            "/project/types/ambient.d.ts",
1675            "/project/types/orphan.d.ts",
1676            "/project/types/helper.ts",
1677        ];
1678        let files: Vec<DiscoveredFile> = paths
1679            .iter()
1680            .zip(0u32..)
1681            .map(|(path, id)| DiscoveredFile {
1682                id: FileId(id),
1683                path: PathBuf::from(path),
1684                size_bytes: 10,
1685            })
1686            .collect();
1687        let resolved_modules = vec![
1688            ResolvedModule {
1689                file_id: FileId(0),
1690                path: files[0].path.clone(),
1691                ..Default::default()
1692            },
1693            ResolvedModule {
1694                file_id: FileId(1),
1695                path: files[1].path.clone(),
1696                resolved_imports: vec![ResolvedImport {
1697                    info: ImportInfo {
1698                        source: "./helper".to_string(),
1699                        imported_name: ImportedName::Named("Helper".to_string()),
1700                        local_name: "Helper".to_string(),
1701                        is_type_only: true,
1702                        is_type_only_star: false,
1703                        from_style: false,
1704                        span: oxc_span::Span::new(0, 10),
1705                        source_span: oxc_span::Span::default(),
1706                    },
1707                    target: ResolveResult::InternalModule(FileId(2)),
1708                }],
1709                ..Default::default()
1710            },
1711            ResolvedModule {
1712                file_id: FileId(2),
1713                path: files[2].path.clone(),
1714                ..Default::default()
1715            },
1716        ];
1717        ModuleGraph::build_with_declaration_seeding(
1718            &resolved_modules,
1719            &[],
1720            &ReachabilityRoots {
1721                all: &[],
1722                runtime: &[],
1723                test: &[],
1724            },
1725            &files,
1726            unseeded,
1727        )
1728    }
1729
1730    #[test]
1731    fn every_declaration_file_is_seeded_by_default() {
1732        let graph = declaration_seeding_graph(&FxHashSet::default());
1733        assert!(graph.modules[0].is_entry_point());
1734        assert!(graph.modules[1].is_entry_point());
1735        assert!(graph.modules[2].is_reachable());
1736    }
1737
1738    #[test]
1739    fn unseeded_declaration_file_no_longer_keeps_its_imports_reachable() {
1740        let unseeded: FxHashSet<FileId> = std::iter::once(FileId(1)).collect();
1741        let graph = declaration_seeding_graph(&unseeded);
1742        assert!(graph.modules[0].is_entry_point());
1743        assert!(!graph.modules[1].is_entry_point());
1744        assert!(!graph.modules[1].is_reachable());
1745        assert!(!graph.modules[2].is_reachable());
1746    }
1747
1748    #[test]
1749    fn graph_module_count() {
1750        let graph = build_simple_graph();
1751        assert_eq!(graph.module_count(), 2);
1752    }
1753
1754    #[test]
1755    fn graph_edge_count() {
1756        let graph = build_simple_graph();
1757        assert_eq!(graph.edge_count(), 1);
1758    }
1759
1760    #[test]
1761    fn graph_entry_point_is_reachable() {
1762        let graph = build_simple_graph();
1763        assert!(graph.modules[0].is_entry_point());
1764        assert!(graph.modules[0].is_reachable());
1765    }
1766
1767    #[test]
1768    fn graph_imported_module_is_reachable() {
1769        let graph = build_simple_graph();
1770        assert!(!graph.modules[1].is_entry_point());
1771        assert!(graph.modules[1].is_reachable());
1772    }
1773
1774    #[test]
1775    #[expect(
1776        clippy::too_many_lines,
1777        reason = "this test fixture exercises four reachability roles end-to-end; splitting it \
1778                  would obscure the cross-role assertions"
1779    )]
1780    fn graph_distinguishes_runtime_test_and_support_reachability() {
1781        let files = vec![
1782            DiscoveredFile {
1783                id: FileId(0),
1784                path: PathBuf::from("/project/src/main.ts"),
1785                size_bytes: 100,
1786            },
1787            DiscoveredFile {
1788                id: FileId(1),
1789                path: PathBuf::from("/project/src/runtime-only.ts"),
1790                size_bytes: 50,
1791            },
1792            DiscoveredFile {
1793                id: FileId(2),
1794                path: PathBuf::from("/project/tests/app.test.ts"),
1795                size_bytes: 50,
1796            },
1797            DiscoveredFile {
1798                id: FileId(3),
1799                path: PathBuf::from("/project/tests/setup.ts"),
1800                size_bytes: 50,
1801            },
1802            DiscoveredFile {
1803                id: FileId(4),
1804                path: PathBuf::from("/project/src/covered.ts"),
1805                size_bytes: 50,
1806            },
1807        ];
1808
1809        let all_entry_points = vec![
1810            EntryPoint {
1811                path: PathBuf::from("/project/src/main.ts"),
1812                source: EntryPointSource::PackageJsonMain,
1813            },
1814            EntryPoint {
1815                path: PathBuf::from("/project/tests/app.test.ts"),
1816                source: EntryPointSource::TestFile,
1817            },
1818            EntryPoint {
1819                path: PathBuf::from("/project/tests/setup.ts"),
1820                source: EntryPointSource::Plugin {
1821                    name: "vitest".to_string(),
1822                },
1823            },
1824        ];
1825        let runtime_entry_points = vec![EntryPoint {
1826            path: PathBuf::from("/project/src/main.ts"),
1827            source: EntryPointSource::PackageJsonMain,
1828        }];
1829        let test_entry_points = vec![EntryPoint {
1830            path: PathBuf::from("/project/tests/app.test.ts"),
1831            source: EntryPointSource::TestFile,
1832        }];
1833
1834        let resolved_modules = vec![
1835            ResolvedModule {
1836                file_id: FileId(0),
1837                path: PathBuf::from("/project/src/main.ts"),
1838                resolved_imports: vec![ResolvedImport {
1839                    info: ImportInfo {
1840                        source: "./runtime-only".to_string(),
1841                        imported_name: ImportedName::Named("runtimeOnly".to_string()),
1842                        local_name: "runtimeOnly".to_string(),
1843                        is_type_only: false,
1844                        is_type_only_star: false,
1845                        from_style: false,
1846                        span: oxc_span::Span::new(0, 10),
1847                        source_span: oxc_span::Span::default(),
1848                    },
1849                    target: ResolveResult::InternalModule(FileId(1)),
1850                }],
1851                ..Default::default()
1852            },
1853            ResolvedModule {
1854                file_id: FileId(1),
1855                path: PathBuf::from("/project/src/runtime-only.ts"),
1856                exports: vec![fallow_types::extract::ExportInfo {
1857                    name: ExportName::Named("runtimeOnly".to_string()),
1858                    local_name: Some("runtimeOnly".to_string()),
1859                    is_type_only: false,
1860                    visibility: VisibilityTag::None,
1861                    expected_unused_reason: None,
1862                    span: oxc_span::Span::new(0, 20),
1863                    members: vec![],
1864                    is_side_effect_used: false,
1865                    super_class: None,
1866                    deprecated: false,
1867                    deprecated_reason: None,
1868                }]
1869                .into(),
1870                ..Default::default()
1871            },
1872            ResolvedModule {
1873                file_id: FileId(2),
1874                path: PathBuf::from("/project/tests/app.test.ts"),
1875                resolved_imports: vec![ResolvedImport {
1876                    info: ImportInfo {
1877                        source: "../src/covered".to_string(),
1878                        imported_name: ImportedName::Named("covered".to_string()),
1879                        local_name: "covered".to_string(),
1880                        is_type_only: false,
1881                        is_type_only_star: false,
1882                        from_style: false,
1883                        span: oxc_span::Span::new(0, 10),
1884                        source_span: oxc_span::Span::default(),
1885                    },
1886                    target: ResolveResult::InternalModule(FileId(4)),
1887                }],
1888                ..Default::default()
1889            },
1890            ResolvedModule {
1891                file_id: FileId(3),
1892                path: PathBuf::from("/project/tests/setup.ts"),
1893                resolved_imports: vec![ResolvedImport {
1894                    info: ImportInfo {
1895                        source: "../src/runtime-only".to_string(),
1896                        imported_name: ImportedName::Named("runtimeOnly".to_string()),
1897                        local_name: "runtimeOnly".to_string(),
1898                        is_type_only: false,
1899                        is_type_only_star: false,
1900                        from_style: false,
1901                        span: oxc_span::Span::new(0, 10),
1902                        source_span: oxc_span::Span::default(),
1903                    },
1904                    target: ResolveResult::InternalModule(FileId(1)),
1905                }],
1906                ..Default::default()
1907            },
1908            ResolvedModule {
1909                file_id: FileId(4),
1910                path: PathBuf::from("/project/src/covered.ts"),
1911                exports: vec![fallow_types::extract::ExportInfo {
1912                    name: ExportName::Named("covered".to_string()),
1913                    local_name: Some("covered".to_string()),
1914                    is_type_only: false,
1915                    visibility: VisibilityTag::None,
1916                    expected_unused_reason: None,
1917                    span: oxc_span::Span::new(0, 20),
1918                    members: vec![],
1919                    is_side_effect_used: false,
1920                    super_class: None,
1921                    deprecated: false,
1922                    deprecated_reason: None,
1923                }]
1924                .into(),
1925                ..Default::default()
1926            },
1927        ];
1928
1929        let graph = ModuleGraph::build_with_reachability_roots(
1930            &resolved_modules,
1931            &all_entry_points,
1932            &runtime_entry_points,
1933            &test_entry_points,
1934            &files,
1935        );
1936
1937        assert!(graph.modules[1].is_reachable());
1938        assert!(graph.modules[1].is_runtime_reachable());
1939        assert!(
1940            !graph.modules[1].is_test_reachable(),
1941            "support roots should not make runtime-only modules test reachable"
1942        );
1943
1944        assert!(graph.modules[4].is_reachable());
1945        assert!(graph.modules[4].is_test_reachable());
1946        assert!(
1947            !graph.modules[4].is_runtime_reachable(),
1948            "test-only reachability should stay separate from runtime roots"
1949        );
1950    }
1951
1952    #[test]
1953    fn graph_export_has_reference() {
1954        let graph = build_simple_graph();
1955        let utils = &graph.modules[1];
1956        let foo_export = utils
1957            .exports
1958            .iter()
1959            .find(|e| e.name.to_string() == "foo")
1960            .unwrap();
1961        assert!(
1962            !foo_export.references.is_empty(),
1963            "foo should have references"
1964        );
1965    }
1966
1967    #[test]
1968    fn graph_unused_export_no_reference() {
1969        let graph = build_simple_graph();
1970        let utils = &graph.modules[1];
1971        let bar_export = utils
1972            .exports
1973            .iter()
1974            .find(|e| e.name.to_string() == "bar")
1975            .unwrap();
1976        assert!(
1977            bar_export.references.is_empty(),
1978            "bar should have no references"
1979        );
1980    }
1981
1982    #[test]
1983    fn graph_no_namespace_import() {
1984        let graph = build_simple_graph();
1985        assert!(!graph.has_namespace_import(FileId(0)));
1986        assert!(!graph.has_namespace_import(FileId(1)));
1987    }
1988
1989    #[test]
1990    fn graph_has_namespace_import() {
1991        let files = vec![
1992            DiscoveredFile {
1993                id: FileId(0),
1994                path: PathBuf::from("/project/entry.ts"),
1995                size_bytes: 100,
1996            },
1997            DiscoveredFile {
1998                id: FileId(1),
1999                path: PathBuf::from("/project/utils.ts"),
2000                size_bytes: 50,
2001            },
2002        ];
2003
2004        let entry_points = vec![EntryPoint {
2005            path: PathBuf::from("/project/entry.ts"),
2006            source: EntryPointSource::PackageJsonMain,
2007        }];
2008
2009        let resolved_modules = vec![
2010            ResolvedModule {
2011                file_id: FileId(0),
2012                path: PathBuf::from("/project/entry.ts"),
2013                resolved_imports: vec![ResolvedImport {
2014                    info: ImportInfo {
2015                        source: "./utils".to_string(),
2016                        imported_name: ImportedName::Namespace,
2017                        local_name: "utils".to_string(),
2018                        is_type_only: false,
2019                        is_type_only_star: false,
2020                        from_style: false,
2021                        span: oxc_span::Span::new(0, 10),
2022                        source_span: oxc_span::Span::default(),
2023                    },
2024                    target: ResolveResult::InternalModule(FileId(1)),
2025                }],
2026                ..Default::default()
2027            },
2028            ResolvedModule {
2029                file_id: FileId(1),
2030                path: PathBuf::from("/project/utils.ts"),
2031                exports: vec![fallow_types::extract::ExportInfo {
2032                    name: ExportName::Named("foo".to_string()),
2033                    local_name: Some("foo".to_string()),
2034                    is_type_only: false,
2035                    visibility: VisibilityTag::None,
2036                    expected_unused_reason: None,
2037                    span: oxc_span::Span::new(0, 20),
2038                    members: vec![],
2039                    is_side_effect_used: false,
2040                    super_class: None,
2041                    deprecated: false,
2042                    deprecated_reason: None,
2043                }]
2044                .into(),
2045                ..Default::default()
2046            },
2047        ];
2048
2049        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2050        assert!(
2051            graph.has_namespace_import(FileId(1)),
2052            "utils should have namespace import"
2053        );
2054    }
2055
2056    #[test]
2057    fn graph_has_namespace_import_out_of_bounds() {
2058        let graph = build_simple_graph();
2059        assert!(!graph.has_namespace_import(FileId(999)));
2060    }
2061
2062    /// The persisted graph cache skips `namespace_imported` and rebuilds it from
2063    /// the edge set on load. This asserts the reconstruction reproduces the
2064    /// fresh-built bitset BIT-FOR-BIT on a graph that exercises `import * as ns`,
2065    /// matching what `build.rs` records at build time.
2066    #[test]
2067    fn reconstruct_namespace_imported_matches_fresh_build() {
2068        let files = vec![
2069            DiscoveredFile {
2070                id: FileId(0),
2071                path: PathBuf::from("/project/entry.ts"),
2072                size_bytes: 100,
2073            },
2074            DiscoveredFile {
2075                id: FileId(1),
2076                path: PathBuf::from("/project/utils.ts"),
2077                size_bytes: 50,
2078            },
2079            DiscoveredFile {
2080                id: FileId(2),
2081                path: PathBuf::from("/project/named-only.ts"),
2082                size_bytes: 50,
2083            },
2084        ];
2085        let entry_points = vec![EntryPoint {
2086            path: PathBuf::from("/project/entry.ts"),
2087            source: EntryPointSource::PackageJsonMain,
2088        }];
2089        let resolved_modules = vec![
2090            ResolvedModule {
2091                file_id: FileId(0),
2092                path: PathBuf::from("/project/entry.ts"),
2093                resolved_imports: vec![
2094                    ResolvedImport {
2095                        info: ImportInfo {
2096                            source: "./utils".to_string(),
2097                            imported_name: ImportedName::Namespace,
2098                            local_name: "utils".to_string(),
2099                            is_type_only: false,
2100                            is_type_only_star: false,
2101                            from_style: false,
2102                            span: oxc_span::Span::new(0, 10),
2103                            source_span: oxc_span::Span::default(),
2104                        },
2105                        target: ResolveResult::InternalModule(FileId(1)),
2106                    },
2107                    ResolvedImport {
2108                        info: ImportInfo {
2109                            source: "./named-only".to_string(),
2110                            imported_name: ImportedName::Named("foo".to_string()),
2111                            local_name: "foo".to_string(),
2112                            is_type_only: false,
2113                            is_type_only_star: false,
2114                            from_style: false,
2115                            span: oxc_span::Span::new(11, 20),
2116                            source_span: oxc_span::Span::default(),
2117                        },
2118                        target: ResolveResult::InternalModule(FileId(2)),
2119                    },
2120                ],
2121                ..Default::default()
2122            },
2123            ResolvedModule {
2124                file_id: FileId(1),
2125                path: PathBuf::from("/project/utils.ts"),
2126                ..Default::default()
2127            },
2128            ResolvedModule {
2129                file_id: FileId(2),
2130                path: PathBuf::from("/project/named-only.ts"),
2131                exports: vec![fallow_types::extract::ExportInfo {
2132                    name: ExportName::Named("foo".to_string()),
2133                    local_name: Some("foo".to_string()),
2134                    is_type_only: false,
2135                    visibility: VisibilityTag::None,
2136                    expected_unused_reason: None,
2137                    span: oxc_span::Span::new(0, 20),
2138                    members: vec![],
2139                    is_side_effect_used: false,
2140                    super_class: None,
2141                    deprecated: false,
2142                    deprecated_reason: None,
2143                }]
2144                .into(),
2145                ..Default::default()
2146            },
2147        ];
2148
2149        let mut graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2150        let fresh = graph.namespace_imported.clone();
2151
2152        // Sanity: the namespace target is set, the named-only target is not.
2153        assert!(graph.has_namespace_import(FileId(1)));
2154        assert!(!graph.has_namespace_import(FileId(2)));
2155
2156        // Simulate the cache load: the bitset arrives empty (serde-skipped), then
2157        // the loader reconstructs it from the persisted edges.
2158        graph.namespace_imported = FixedBitSet::default();
2159        graph.reconstruct_namespace_imported();
2160
2161        assert_eq!(
2162            graph.namespace_imported, fresh,
2163            "reconstructed namespace_imported must equal the fresh-built bitset"
2164        );
2165        assert!(graph.has_namespace_import(FileId(1)));
2166        assert!(!graph.has_namespace_import(FileId(2)));
2167    }
2168
2169    #[test]
2170    fn graph_unreachable_module() {
2171        let files = vec![
2172            DiscoveredFile {
2173                id: FileId(0),
2174                path: PathBuf::from("/project/entry.ts"),
2175                size_bytes: 100,
2176            },
2177            DiscoveredFile {
2178                id: FileId(1),
2179                path: PathBuf::from("/project/utils.ts"),
2180                size_bytes: 50,
2181            },
2182            DiscoveredFile {
2183                id: FileId(2),
2184                path: PathBuf::from("/project/orphan.ts"),
2185                size_bytes: 30,
2186            },
2187        ];
2188
2189        let entry_points = vec![EntryPoint {
2190            path: PathBuf::from("/project/entry.ts"),
2191            source: EntryPointSource::PackageJsonMain,
2192        }];
2193
2194        let resolved_modules = vec![
2195            ResolvedModule {
2196                file_id: FileId(0),
2197                path: PathBuf::from("/project/entry.ts"),
2198                resolved_imports: vec![ResolvedImport {
2199                    info: ImportInfo {
2200                        source: "./utils".to_string(),
2201                        imported_name: ImportedName::Named("foo".to_string()),
2202                        local_name: "foo".to_string(),
2203                        is_type_only: false,
2204                        is_type_only_star: false,
2205                        from_style: false,
2206                        span: oxc_span::Span::new(0, 10),
2207                        source_span: oxc_span::Span::default(),
2208                    },
2209                    target: ResolveResult::InternalModule(FileId(1)),
2210                }],
2211                ..Default::default()
2212            },
2213            ResolvedModule {
2214                file_id: FileId(1),
2215                path: PathBuf::from("/project/utils.ts"),
2216                exports: vec![fallow_types::extract::ExportInfo {
2217                    name: ExportName::Named("foo".to_string()),
2218                    local_name: Some("foo".to_string()),
2219                    is_type_only: false,
2220                    visibility: VisibilityTag::None,
2221                    expected_unused_reason: None,
2222                    span: oxc_span::Span::new(0, 20),
2223                    members: vec![],
2224                    is_side_effect_used: false,
2225                    super_class: None,
2226                    deprecated: false,
2227                    deprecated_reason: None,
2228                }]
2229                .into(),
2230                ..Default::default()
2231            },
2232            ResolvedModule {
2233                file_id: FileId(2),
2234                path: PathBuf::from("/project/orphan.ts"),
2235                exports: vec![fallow_types::extract::ExportInfo {
2236                    name: ExportName::Named("orphan".to_string()),
2237                    local_name: Some("orphan".to_string()),
2238                    is_type_only: false,
2239                    visibility: VisibilityTag::None,
2240                    expected_unused_reason: None,
2241                    span: oxc_span::Span::new(0, 20),
2242                    members: vec![],
2243                    is_side_effect_used: false,
2244                    super_class: None,
2245                    deprecated: false,
2246                    deprecated_reason: None,
2247                }]
2248                .into(),
2249                ..Default::default()
2250            },
2251        ];
2252
2253        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2254
2255        assert!(graph.modules[0].is_reachable(), "entry should be reachable");
2256        assert!(graph.modules[1].is_reachable(), "utils should be reachable");
2257        assert!(
2258            !graph.modules[2].is_reachable(),
2259            "orphan should NOT be reachable"
2260        );
2261    }
2262
2263    #[test]
2264    fn graph_package_usage_tracked() {
2265        let files = vec![DiscoveredFile {
2266            id: FileId(0),
2267            path: PathBuf::from("/project/entry.ts"),
2268            size_bytes: 100,
2269        }];
2270
2271        let entry_points = vec![EntryPoint {
2272            path: PathBuf::from("/project/entry.ts"),
2273            source: EntryPointSource::PackageJsonMain,
2274        }];
2275
2276        let resolved_modules = vec![ResolvedModule {
2277            file_id: FileId(0),
2278            path: PathBuf::from("/project/entry.ts"),
2279            exports: vec![].into(),
2280            re_exports: vec![],
2281            resolved_imports: vec![
2282                ResolvedImport {
2283                    info: ImportInfo {
2284                        source: "react".to_string(),
2285                        imported_name: ImportedName::Default,
2286                        local_name: "React".to_string(),
2287                        is_type_only: false,
2288                        is_type_only_star: false,
2289                        from_style: false,
2290                        span: oxc_span::Span::new(0, 10),
2291                        source_span: oxc_span::Span::default(),
2292                    },
2293                    target: ResolveResult::NpmPackage("react".to_string()),
2294                },
2295                ResolvedImport {
2296                    info: ImportInfo {
2297                        source: "lodash".to_string(),
2298                        imported_name: ImportedName::Named("merge".to_string()),
2299                        local_name: "merge".to_string(),
2300                        is_type_only: false,
2301                        is_type_only_star: false,
2302                        from_style: false,
2303                        span: oxc_span::Span::new(15, 30),
2304                        source_span: oxc_span::Span::default(),
2305                    },
2306                    target: ResolveResult::NpmPackage("lodash".to_string()),
2307                },
2308            ],
2309            ..Default::default()
2310        }];
2311
2312        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2313        assert!(graph.package_usage.contains_key("react"));
2314        assert!(graph.package_usage.contains_key("lodash"));
2315        assert!(!graph.package_usage.contains_key("express"));
2316    }
2317
2318    #[test]
2319    fn graph_empty() {
2320        let graph = ModuleGraph::build(&[], &[], &[]);
2321        assert_eq!(graph.module_count(), 0);
2322        assert_eq!(graph.edge_count(), 0);
2323    }
2324
2325    /// The persisted graph cache postcard-encodes the whole `ModuleGraph` and
2326    /// decodes it on a warm run. This proves the serde round-trip is lossless
2327    /// for the structural surface analysis reads: module / edge / export /
2328    /// reference counts and the `namespace_imported` bitset (reconstructed on
2329    /// load) all survive.
2330    #[test]
2331    fn graph_postcard_round_trip_is_lossless() {
2332        let graph = build_simple_graph();
2333
2334        let encoded = postcard::to_allocvec(&graph).expect("encode graph");
2335        let mut decoded: ModuleGraph = postcard::from_bytes(&encoded).expect("decode graph");
2336        // The store does this on load; do it here so the bitset is restored.
2337        decoded.reconstruct_namespace_imported();
2338
2339        assert_eq!(decoded.module_count(), graph.module_count());
2340        assert_eq!(decoded.edge_count(), graph.edge_count());
2341        assert_eq!(decoded.namespace_imported, graph.namespace_imported);
2342
2343        // Export + reference + member surface survives byte-for-byte.
2344        let utils = &decoded.modules[1];
2345        let foo = utils
2346            .exports
2347            .iter()
2348            .find(|e| e.name.to_string() == "foo")
2349            .expect("foo export survives round-trip");
2350        assert!(!foo.references.is_empty());
2351        let bar = utils
2352            .exports
2353            .iter()
2354            .find(|e| e.name.to_string() == "bar")
2355            .expect("bar export survives round-trip");
2356        assert!(bar.references.is_empty());
2357
2358        // Reachability flags and entry-point sets survive.
2359        assert!(decoded.modules[0].is_entry_point());
2360        assert!(decoded.modules[0].is_reachable());
2361        assert!(decoded.modules[1].is_reachable());
2362        assert_eq!(decoded.entry_points, graph.entry_points);
2363    }
2364
2365    #[test]
2366    fn graph_cjs_exports_tracked() {
2367        let files = vec![DiscoveredFile {
2368            id: FileId(0),
2369            path: PathBuf::from("/project/entry.ts"),
2370            size_bytes: 100,
2371        }];
2372
2373        let entry_points = vec![EntryPoint {
2374            path: PathBuf::from("/project/entry.ts"),
2375            source: EntryPointSource::PackageJsonMain,
2376        }];
2377
2378        let resolved_modules = vec![ResolvedModule {
2379            file_id: FileId(0),
2380            path: PathBuf::from("/project/entry.ts"),
2381            has_cjs_exports: true,
2382            has_angular_component_template_url: false,
2383            ..Default::default()
2384        }];
2385
2386        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2387        assert!(graph.modules[0].has_cjs_exports());
2388    }
2389
2390    #[test]
2391    fn graph_edges_for_returns_targets() {
2392        let graph = build_simple_graph();
2393        let targets = graph.edges_for(FileId(0));
2394        assert_eq!(targets, vec![FileId(1)]);
2395    }
2396
2397    #[test]
2398    fn graph_edges_for_no_imports() {
2399        let graph = build_simple_graph();
2400        let targets = graph.edges_for(FileId(1));
2401        assert!(targets.is_empty());
2402    }
2403
2404    #[test]
2405    fn graph_edges_for_out_of_bounds() {
2406        let graph = build_simple_graph();
2407        let targets = graph.edges_for(FileId(999));
2408        assert!(targets.is_empty());
2409    }
2410
2411    #[test]
2412    fn graph_direct_importer_summaries_include_symbols() {
2413        let graph = build_simple_graph();
2414        let summaries = graph.direct_importer_summaries(FileId(1));
2415
2416        assert_eq!(
2417            summaries,
2418            vec![DirectImporterSummary {
2419                source: FileId(0),
2420                symbols: vec![ImportedSymbolSummary {
2421                    imported: "foo".to_string(),
2422                    local: "foo".to_string(),
2423                    type_only: false,
2424                }],
2425            }]
2426        );
2427    }
2428
2429    #[test]
2430    fn graph_find_import_span_start_found() {
2431        let graph = build_simple_graph();
2432        let span_start = graph.find_import_span_start(FileId(0), FileId(1));
2433        assert!(span_start.is_some());
2434        assert_eq!(span_start.unwrap(), 0);
2435    }
2436
2437    #[test]
2438    fn graph_find_import_span_start_prefers_value_import_on_mixed_edge() {
2439        let files = vec![
2440            DiscoveredFile {
2441                id: FileId(0),
2442                path: PathBuf::from("/project/entry.ts"),
2443                size_bytes: 100,
2444            },
2445            DiscoveredFile {
2446                id: FileId(1),
2447                path: PathBuf::from("/project/utils.ts"),
2448                size_bytes: 50,
2449            },
2450        ];
2451        let entry_points = vec![EntryPoint {
2452            path: PathBuf::from("/project/entry.ts"),
2453            source: EntryPointSource::PackageJsonMain,
2454        }];
2455        let resolved_modules = vec![
2456            ResolvedModule {
2457                file_id: FileId(0),
2458                path: PathBuf::from("/project/entry.ts"),
2459                resolved_imports: vec![
2460                    ResolvedImport {
2461                        info: ImportInfo {
2462                            source: "./utils".to_string(),
2463                            imported_name: ImportedName::Named("Foo".to_string()),
2464                            local_name: "Foo".to_string(),
2465                            is_type_only: true,
2466                            is_type_only_star: false,
2467                            from_style: false,
2468                            span: oxc_span::Span::new(10, 20),
2469                            source_span: oxc_span::Span::default(),
2470                        },
2471                        target: ResolveResult::InternalModule(FileId(1)),
2472                    },
2473                    ResolvedImport {
2474                        info: ImportInfo {
2475                            source: "./utils".to_string(),
2476                            imported_name: ImportedName::Named("foo".to_string()),
2477                            local_name: "foo".to_string(),
2478                            is_type_only: false,
2479                            is_type_only_star: false,
2480                            from_style: false,
2481                            span: oxc_span::Span::new(50, 60),
2482                            source_span: oxc_span::Span::default(),
2483                        },
2484                        target: ResolveResult::InternalModule(FileId(1)),
2485                    },
2486                ],
2487                ..Default::default()
2488            },
2489            ResolvedModule {
2490                file_id: FileId(1),
2491                path: PathBuf::from("/project/utils.ts"),
2492                ..Default::default()
2493            },
2494        ];
2495
2496        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2497        assert_eq!(graph.find_import_span_start(FileId(0), FileId(1)), Some(50));
2498    }
2499
2500    #[test]
2501    fn graph_find_import_span_start_wrong_target() {
2502        let graph = build_simple_graph();
2503        let span_start = graph.find_import_span_start(FileId(0), FileId(0));
2504        assert!(span_start.is_none());
2505    }
2506
2507    #[test]
2508    fn graph_find_import_span_start_source_out_of_bounds() {
2509        let graph = build_simple_graph();
2510        let span_start = graph.find_import_span_start(FileId(999), FileId(1));
2511        assert!(span_start.is_none());
2512    }
2513
2514    #[test]
2515    fn graph_find_import_span_start_no_edges() {
2516        let graph = build_simple_graph();
2517        let span_start = graph.find_import_span_start(FileId(1), FileId(0));
2518        assert!(span_start.is_none());
2519    }
2520
2521    #[test]
2522    fn graph_reverse_deps_populated() {
2523        let graph = build_simple_graph();
2524        assert!(graph.reverse_deps[1].contains(&FileId(0)));
2525        assert!(graph.reverse_deps[0].is_empty());
2526    }
2527
2528    #[test]
2529    fn graph_type_only_package_usage_tracked() {
2530        let files = vec![DiscoveredFile {
2531            id: FileId(0),
2532            path: PathBuf::from("/project/entry.ts"),
2533            size_bytes: 100,
2534        }];
2535        let entry_points = vec![EntryPoint {
2536            path: PathBuf::from("/project/entry.ts"),
2537            source: EntryPointSource::PackageJsonMain,
2538        }];
2539        let resolved_modules = vec![ResolvedModule {
2540            file_id: FileId(0),
2541            path: PathBuf::from("/project/entry.ts"),
2542            resolved_imports: vec![
2543                ResolvedImport {
2544                    info: ImportInfo {
2545                        source: "react".to_string(),
2546                        imported_name: ImportedName::Named("FC".to_string()),
2547                        local_name: "FC".to_string(),
2548                        is_type_only: true,
2549                        is_type_only_star: false,
2550                        from_style: false,
2551                        span: oxc_span::Span::new(0, 10),
2552                        source_span: oxc_span::Span::default(),
2553                    },
2554                    target: ResolveResult::NpmPackage("react".to_string()),
2555                },
2556                ResolvedImport {
2557                    info: ImportInfo {
2558                        source: "react".to_string(),
2559                        imported_name: ImportedName::Named("useState".to_string()),
2560                        local_name: "useState".to_string(),
2561                        is_type_only: false,
2562                        is_type_only_star: false,
2563                        from_style: false,
2564                        span: oxc_span::Span::new(15, 30),
2565                        source_span: oxc_span::Span::default(),
2566                    },
2567                    target: ResolveResult::NpmPackage("react".to_string()),
2568                },
2569            ],
2570            ..Default::default()
2571        }];
2572
2573        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2574        assert!(graph.package_usage.contains_key("react"));
2575        assert!(graph.type_only_package_usage.contains_key("react"));
2576    }
2577
2578    #[test]
2579    fn graph_default_import_reference() {
2580        let files = vec![
2581            DiscoveredFile {
2582                id: FileId(0),
2583                path: PathBuf::from("/project/entry.ts"),
2584                size_bytes: 100,
2585            },
2586            DiscoveredFile {
2587                id: FileId(1),
2588                path: PathBuf::from("/project/utils.ts"),
2589                size_bytes: 50,
2590            },
2591        ];
2592        let entry_points = vec![EntryPoint {
2593            path: PathBuf::from("/project/entry.ts"),
2594            source: EntryPointSource::PackageJsonMain,
2595        }];
2596        let resolved_modules = vec![
2597            ResolvedModule {
2598                file_id: FileId(0),
2599                path: PathBuf::from("/project/entry.ts"),
2600                resolved_imports: vec![ResolvedImport {
2601                    info: ImportInfo {
2602                        source: "./utils".to_string(),
2603                        imported_name: ImportedName::Default,
2604                        local_name: "Utils".to_string(),
2605                        is_type_only: false,
2606                        is_type_only_star: false,
2607                        from_style: false,
2608                        span: oxc_span::Span::new(0, 10),
2609                        source_span: oxc_span::Span::default(),
2610                    },
2611                    target: ResolveResult::InternalModule(FileId(1)),
2612                }],
2613                ..Default::default()
2614            },
2615            ResolvedModule {
2616                file_id: FileId(1),
2617                path: PathBuf::from("/project/utils.ts"),
2618                exports: vec![fallow_types::extract::ExportInfo {
2619                    name: ExportName::Default,
2620                    local_name: None,
2621                    is_type_only: false,
2622                    visibility: VisibilityTag::None,
2623                    expected_unused_reason: None,
2624                    span: oxc_span::Span::new(0, 20),
2625                    members: vec![],
2626                    is_side_effect_used: false,
2627                    super_class: None,
2628                    deprecated: false,
2629                    deprecated_reason: None,
2630                }]
2631                .into(),
2632                ..Default::default()
2633            },
2634        ];
2635
2636        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2637        let utils = &graph.modules[1];
2638        let default_export = utils
2639            .exports
2640            .iter()
2641            .find(|e| matches!(e.name, ExportName::Default))
2642            .unwrap();
2643        assert!(!default_export.references.is_empty());
2644        assert_eq!(
2645            default_export.references[0].kind,
2646            ReferenceKind::DefaultImport
2647        );
2648    }
2649
2650    #[test]
2651    fn graph_side_effect_import_no_export_reference() {
2652        let files = vec![
2653            DiscoveredFile {
2654                id: FileId(0),
2655                path: PathBuf::from("/project/entry.ts"),
2656                size_bytes: 100,
2657            },
2658            DiscoveredFile {
2659                id: FileId(1),
2660                path: PathBuf::from("/project/styles.ts"),
2661                size_bytes: 50,
2662            },
2663        ];
2664        let entry_points = vec![EntryPoint {
2665            path: PathBuf::from("/project/entry.ts"),
2666            source: EntryPointSource::PackageJsonMain,
2667        }];
2668        let resolved_modules = vec![
2669            ResolvedModule {
2670                file_id: FileId(0),
2671                path: PathBuf::from("/project/entry.ts"),
2672                resolved_imports: vec![ResolvedImport {
2673                    info: ImportInfo {
2674                        source: "./styles".to_string(),
2675                        imported_name: ImportedName::SideEffect,
2676                        local_name: String::new(),
2677                        is_type_only: false,
2678                        is_type_only_star: false,
2679                        from_style: false,
2680                        span: oxc_span::Span::new(0, 10),
2681                        source_span: oxc_span::Span::default(),
2682                    },
2683                    target: ResolveResult::InternalModule(FileId(1)),
2684                }],
2685                ..Default::default()
2686            },
2687            ResolvedModule {
2688                file_id: FileId(1),
2689                path: PathBuf::from("/project/styles.ts"),
2690                exports: vec![fallow_types::extract::ExportInfo {
2691                    name: ExportName::Named("primaryColor".to_string()),
2692                    local_name: Some("primaryColor".to_string()),
2693                    is_type_only: false,
2694                    visibility: VisibilityTag::None,
2695                    expected_unused_reason: None,
2696                    span: oxc_span::Span::new(0, 20),
2697                    members: vec![],
2698                    is_side_effect_used: false,
2699                    super_class: None,
2700                    deprecated: false,
2701                    deprecated_reason: None,
2702                }]
2703                .into(),
2704                ..Default::default()
2705            },
2706        ];
2707
2708        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2709        assert_eq!(graph.edge_count(), 1);
2710        let styles = &graph.modules[1];
2711        assert!(styles.is_reachable());
2712        let export = &styles.exports[0];
2713        assert!(
2714            export.references.is_empty(),
2715            "side-effect import should not reference named exports"
2716        );
2717
2718        let encoded = postcard::to_allocvec(&graph).expect("encode graph");
2719        let decoded: ModuleGraph = postcard::from_bytes(&encoded).expect("decode graph");
2720        assert_eq!(decoded.edge_count(), 1);
2721        assert!(decoded.modules[1].is_reachable());
2722        assert!(decoded.modules[1].exports[0].references.is_empty());
2723    }
2724
2725    #[test]
2726    fn graph_multiple_entry_points() {
2727        let files = vec![
2728            DiscoveredFile {
2729                id: FileId(0),
2730                path: PathBuf::from("/project/main.ts"),
2731                size_bytes: 100,
2732            },
2733            DiscoveredFile {
2734                id: FileId(1),
2735                path: PathBuf::from("/project/worker.ts"),
2736                size_bytes: 100,
2737            },
2738            DiscoveredFile {
2739                id: FileId(2),
2740                path: PathBuf::from("/project/shared.ts"),
2741                size_bytes: 50,
2742            },
2743        ];
2744        let entry_points = vec![
2745            EntryPoint {
2746                path: PathBuf::from("/project/main.ts"),
2747                source: EntryPointSource::PackageJsonMain,
2748            },
2749            EntryPoint {
2750                path: PathBuf::from("/project/worker.ts"),
2751                source: EntryPointSource::PackageJsonMain,
2752            },
2753        ];
2754        let resolved_modules = vec![
2755            ResolvedModule {
2756                file_id: FileId(0),
2757                path: PathBuf::from("/project/main.ts"),
2758                resolved_imports: vec![ResolvedImport {
2759                    info: ImportInfo {
2760                        source: "./shared".to_string(),
2761                        imported_name: ImportedName::Named("helper".to_string()),
2762                        local_name: "helper".to_string(),
2763                        is_type_only: false,
2764                        is_type_only_star: false,
2765                        from_style: false,
2766                        span: oxc_span::Span::new(0, 10),
2767                        source_span: oxc_span::Span::default(),
2768                    },
2769                    target: ResolveResult::InternalModule(FileId(2)),
2770                }],
2771                ..Default::default()
2772            },
2773            ResolvedModule {
2774                file_id: FileId(1),
2775                path: PathBuf::from("/project/worker.ts"),
2776                ..Default::default()
2777            },
2778            ResolvedModule {
2779                file_id: FileId(2),
2780                path: PathBuf::from("/project/shared.ts"),
2781                exports: vec![fallow_types::extract::ExportInfo {
2782                    name: ExportName::Named("helper".to_string()),
2783                    local_name: Some("helper".to_string()),
2784                    is_type_only: false,
2785                    visibility: VisibilityTag::None,
2786                    expected_unused_reason: None,
2787                    span: oxc_span::Span::new(0, 20),
2788                    members: vec![],
2789                    is_side_effect_used: false,
2790                    super_class: None,
2791                    deprecated: false,
2792                    deprecated_reason: None,
2793                }]
2794                .into(),
2795                ..Default::default()
2796            },
2797        ];
2798
2799        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2800        assert!(graph.modules[0].is_entry_point());
2801        assert!(graph.modules[1].is_entry_point());
2802        assert!(!graph.modules[2].is_entry_point());
2803        assert!(graph.modules[0].is_reachable());
2804        assert!(graph.modules[1].is_reachable());
2805        assert!(graph.modules[2].is_reachable());
2806    }
2807}