Skip to main content

fallow_graph/graph/
mod.rs

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