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