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