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