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