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