Skip to main content

fallow_graph/graph/
mod.rs

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