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