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