Skip to main content

fallow_graph/graph/
mod.rs

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