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