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