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                .into(),
1005                ..Default::default()
1006            },
1007        ];
1008
1009        ModuleGraph::build(&resolved_modules, &entry_points, &files)
1010    }
1011
1012    #[test]
1013    fn graph_module_count() {
1014        let graph = build_simple_graph();
1015        assert_eq!(graph.module_count(), 2);
1016    }
1017
1018    #[test]
1019    fn graph_edge_count() {
1020        let graph = build_simple_graph();
1021        assert_eq!(graph.edge_count(), 1);
1022    }
1023
1024    #[test]
1025    fn graph_entry_point_is_reachable() {
1026        let graph = build_simple_graph();
1027        assert!(graph.modules[0].is_entry_point());
1028        assert!(graph.modules[0].is_reachable());
1029    }
1030
1031    #[test]
1032    fn graph_imported_module_is_reachable() {
1033        let graph = build_simple_graph();
1034        assert!(!graph.modules[1].is_entry_point());
1035        assert!(graph.modules[1].is_reachable());
1036    }
1037
1038    #[test]
1039    #[expect(
1040        clippy::too_many_lines,
1041        reason = "this test fixture exercises four reachability roles end-to-end; splitting it \
1042                  would obscure the cross-role assertions"
1043    )]
1044    fn graph_distinguishes_runtime_test_and_support_reachability() {
1045        let files = vec![
1046            DiscoveredFile {
1047                id: FileId(0),
1048                path: PathBuf::from("/project/src/main.ts"),
1049                size_bytes: 100,
1050            },
1051            DiscoveredFile {
1052                id: FileId(1),
1053                path: PathBuf::from("/project/src/runtime-only.ts"),
1054                size_bytes: 50,
1055            },
1056            DiscoveredFile {
1057                id: FileId(2),
1058                path: PathBuf::from("/project/tests/app.test.ts"),
1059                size_bytes: 50,
1060            },
1061            DiscoveredFile {
1062                id: FileId(3),
1063                path: PathBuf::from("/project/tests/setup.ts"),
1064                size_bytes: 50,
1065            },
1066            DiscoveredFile {
1067                id: FileId(4),
1068                path: PathBuf::from("/project/src/covered.ts"),
1069                size_bytes: 50,
1070            },
1071        ];
1072
1073        let all_entry_points = vec![
1074            EntryPoint {
1075                path: PathBuf::from("/project/src/main.ts"),
1076                source: EntryPointSource::PackageJsonMain,
1077            },
1078            EntryPoint {
1079                path: PathBuf::from("/project/tests/app.test.ts"),
1080                source: EntryPointSource::TestFile,
1081            },
1082            EntryPoint {
1083                path: PathBuf::from("/project/tests/setup.ts"),
1084                source: EntryPointSource::Plugin {
1085                    name: "vitest".to_string(),
1086                },
1087            },
1088        ];
1089        let runtime_entry_points = vec![EntryPoint {
1090            path: PathBuf::from("/project/src/main.ts"),
1091            source: EntryPointSource::PackageJsonMain,
1092        }];
1093        let test_entry_points = vec![EntryPoint {
1094            path: PathBuf::from("/project/tests/app.test.ts"),
1095            source: EntryPointSource::TestFile,
1096        }];
1097
1098        let resolved_modules = vec![
1099            ResolvedModule {
1100                file_id: FileId(0),
1101                path: PathBuf::from("/project/src/main.ts"),
1102                resolved_imports: vec![ResolvedImport {
1103                    info: ImportInfo {
1104                        source: "./runtime-only".to_string(),
1105                        imported_name: ImportedName::Named("runtimeOnly".to_string()),
1106                        local_name: "runtimeOnly".to_string(),
1107                        is_type_only: false,
1108                        from_style: false,
1109                        span: oxc_span::Span::new(0, 10),
1110                        source_span: oxc_span::Span::default(),
1111                    },
1112                    target: ResolveResult::InternalModule(FileId(1)),
1113                }],
1114                ..Default::default()
1115            },
1116            ResolvedModule {
1117                file_id: FileId(1),
1118                path: PathBuf::from("/project/src/runtime-only.ts"),
1119                exports: vec![fallow_types::extract::ExportInfo {
1120                    name: ExportName::Named("runtimeOnly".to_string()),
1121                    local_name: Some("runtimeOnly".to_string()),
1122                    is_type_only: false,
1123                    visibility: VisibilityTag::None,
1124                    expected_unused_reason: None,
1125                    span: oxc_span::Span::new(0, 20),
1126                    members: vec![],
1127                    is_side_effect_used: false,
1128                    super_class: None,
1129                }]
1130                .into(),
1131                ..Default::default()
1132            },
1133            ResolvedModule {
1134                file_id: FileId(2),
1135                path: PathBuf::from("/project/tests/app.test.ts"),
1136                resolved_imports: vec![ResolvedImport {
1137                    info: ImportInfo {
1138                        source: "../src/covered".to_string(),
1139                        imported_name: ImportedName::Named("covered".to_string()),
1140                        local_name: "covered".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(4)),
1147                }],
1148                ..Default::default()
1149            },
1150            ResolvedModule {
1151                file_id: FileId(3),
1152                path: PathBuf::from("/project/tests/setup.ts"),
1153                resolved_imports: vec![ResolvedImport {
1154                    info: ImportInfo {
1155                        source: "../src/runtime-only".to_string(),
1156                        imported_name: ImportedName::Named("runtimeOnly".to_string()),
1157                        local_name: "runtimeOnly".to_string(),
1158                        is_type_only: false,
1159                        from_style: false,
1160                        span: oxc_span::Span::new(0, 10),
1161                        source_span: oxc_span::Span::default(),
1162                    },
1163                    target: ResolveResult::InternalModule(FileId(1)),
1164                }],
1165                ..Default::default()
1166            },
1167            ResolvedModule {
1168                file_id: FileId(4),
1169                path: PathBuf::from("/project/src/covered.ts"),
1170                exports: vec![fallow_types::extract::ExportInfo {
1171                    name: ExportName::Named("covered".to_string()),
1172                    local_name: Some("covered".to_string()),
1173                    is_type_only: false,
1174                    visibility: VisibilityTag::None,
1175                    expected_unused_reason: None,
1176                    span: oxc_span::Span::new(0, 20),
1177                    members: vec![],
1178                    is_side_effect_used: false,
1179                    super_class: None,
1180                }]
1181                .into(),
1182                ..Default::default()
1183            },
1184        ];
1185
1186        let graph = ModuleGraph::build_with_reachability_roots(
1187            &resolved_modules,
1188            &all_entry_points,
1189            &runtime_entry_points,
1190            &test_entry_points,
1191            &files,
1192        );
1193
1194        assert!(graph.modules[1].is_reachable());
1195        assert!(graph.modules[1].is_runtime_reachable());
1196        assert!(
1197            !graph.modules[1].is_test_reachable(),
1198            "support roots should not make runtime-only modules test reachable"
1199        );
1200
1201        assert!(graph.modules[4].is_reachable());
1202        assert!(graph.modules[4].is_test_reachable());
1203        assert!(
1204            !graph.modules[4].is_runtime_reachable(),
1205            "test-only reachability should stay separate from runtime roots"
1206        );
1207    }
1208
1209    #[test]
1210    fn graph_export_has_reference() {
1211        let graph = build_simple_graph();
1212        let utils = &graph.modules[1];
1213        let foo_export = utils
1214            .exports
1215            .iter()
1216            .find(|e| e.name.to_string() == "foo")
1217            .unwrap();
1218        assert!(
1219            !foo_export.references.is_empty(),
1220            "foo should have references"
1221        );
1222    }
1223
1224    #[test]
1225    fn graph_unused_export_no_reference() {
1226        let graph = build_simple_graph();
1227        let utils = &graph.modules[1];
1228        let bar_export = utils
1229            .exports
1230            .iter()
1231            .find(|e| e.name.to_string() == "bar")
1232            .unwrap();
1233        assert!(
1234            bar_export.references.is_empty(),
1235            "bar should have no references"
1236        );
1237    }
1238
1239    #[test]
1240    fn graph_no_namespace_import() {
1241        let graph = build_simple_graph();
1242        assert!(!graph.has_namespace_import(FileId(0)));
1243        assert!(!graph.has_namespace_import(FileId(1)));
1244    }
1245
1246    #[test]
1247    fn graph_has_namespace_import() {
1248        let files = vec![
1249            DiscoveredFile {
1250                id: FileId(0),
1251                path: PathBuf::from("/project/entry.ts"),
1252                size_bytes: 100,
1253            },
1254            DiscoveredFile {
1255                id: FileId(1),
1256                path: PathBuf::from("/project/utils.ts"),
1257                size_bytes: 50,
1258            },
1259        ];
1260
1261        let entry_points = vec![EntryPoint {
1262            path: PathBuf::from("/project/entry.ts"),
1263            source: EntryPointSource::PackageJsonMain,
1264        }];
1265
1266        let resolved_modules = vec![
1267            ResolvedModule {
1268                file_id: FileId(0),
1269                path: PathBuf::from("/project/entry.ts"),
1270                resolved_imports: vec![ResolvedImport {
1271                    info: ImportInfo {
1272                        source: "./utils".to_string(),
1273                        imported_name: ImportedName::Namespace,
1274                        local_name: "utils".to_string(),
1275                        is_type_only: false,
1276                        from_style: false,
1277                        span: oxc_span::Span::new(0, 10),
1278                        source_span: oxc_span::Span::default(),
1279                    },
1280                    target: ResolveResult::InternalModule(FileId(1)),
1281                }],
1282                ..Default::default()
1283            },
1284            ResolvedModule {
1285                file_id: FileId(1),
1286                path: PathBuf::from("/project/utils.ts"),
1287                exports: vec![fallow_types::extract::ExportInfo {
1288                    name: ExportName::Named("foo".to_string()),
1289                    local_name: Some("foo".to_string()),
1290                    is_type_only: false,
1291                    visibility: VisibilityTag::None,
1292                    expected_unused_reason: None,
1293                    span: oxc_span::Span::new(0, 20),
1294                    members: vec![],
1295                    is_side_effect_used: false,
1296                    super_class: None,
1297                }]
1298                .into(),
1299                ..Default::default()
1300            },
1301        ];
1302
1303        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1304        assert!(
1305            graph.has_namespace_import(FileId(1)),
1306            "utils should have namespace import"
1307        );
1308    }
1309
1310    #[test]
1311    fn graph_has_namespace_import_out_of_bounds() {
1312        let graph = build_simple_graph();
1313        assert!(!graph.has_namespace_import(FileId(999)));
1314    }
1315
1316    /// The persisted graph cache skips `namespace_imported` and rebuilds it from
1317    /// the edge set on load. This asserts the reconstruction reproduces the
1318    /// fresh-built bitset BIT-FOR-BIT on a graph that exercises `import * as ns`,
1319    /// matching what `build.rs` records at build time.
1320    #[test]
1321    fn reconstruct_namespace_imported_matches_fresh_build() {
1322        let files = vec![
1323            DiscoveredFile {
1324                id: FileId(0),
1325                path: PathBuf::from("/project/entry.ts"),
1326                size_bytes: 100,
1327            },
1328            DiscoveredFile {
1329                id: FileId(1),
1330                path: PathBuf::from("/project/utils.ts"),
1331                size_bytes: 50,
1332            },
1333            DiscoveredFile {
1334                id: FileId(2),
1335                path: PathBuf::from("/project/named-only.ts"),
1336                size_bytes: 50,
1337            },
1338        ];
1339        let entry_points = vec![EntryPoint {
1340            path: PathBuf::from("/project/entry.ts"),
1341            source: EntryPointSource::PackageJsonMain,
1342        }];
1343        let resolved_modules = vec![
1344            ResolvedModule {
1345                file_id: FileId(0),
1346                path: PathBuf::from("/project/entry.ts"),
1347                resolved_imports: vec![
1348                    ResolvedImport {
1349                        info: ImportInfo {
1350                            source: "./utils".to_string(),
1351                            imported_name: ImportedName::Namespace,
1352                            local_name: "utils".to_string(),
1353                            is_type_only: false,
1354                            from_style: false,
1355                            span: oxc_span::Span::new(0, 10),
1356                            source_span: oxc_span::Span::default(),
1357                        },
1358                        target: ResolveResult::InternalModule(FileId(1)),
1359                    },
1360                    ResolvedImport {
1361                        info: ImportInfo {
1362                            source: "./named-only".to_string(),
1363                            imported_name: ImportedName::Named("foo".to_string()),
1364                            local_name: "foo".to_string(),
1365                            is_type_only: false,
1366                            from_style: false,
1367                            span: oxc_span::Span::new(11, 20),
1368                            source_span: oxc_span::Span::default(),
1369                        },
1370                        target: ResolveResult::InternalModule(FileId(2)),
1371                    },
1372                ],
1373                ..Default::default()
1374            },
1375            ResolvedModule {
1376                file_id: FileId(1),
1377                path: PathBuf::from("/project/utils.ts"),
1378                ..Default::default()
1379            },
1380            ResolvedModule {
1381                file_id: FileId(2),
1382                path: PathBuf::from("/project/named-only.ts"),
1383                exports: vec![fallow_types::extract::ExportInfo {
1384                    name: ExportName::Named("foo".to_string()),
1385                    local_name: Some("foo".to_string()),
1386                    is_type_only: false,
1387                    visibility: VisibilityTag::None,
1388                    expected_unused_reason: None,
1389                    span: oxc_span::Span::new(0, 20),
1390                    members: vec![],
1391                    is_side_effect_used: false,
1392                    super_class: None,
1393                }]
1394                .into(),
1395                ..Default::default()
1396            },
1397        ];
1398
1399        let mut graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1400        let fresh = graph.namespace_imported.clone();
1401
1402        // Sanity: the namespace target is set, the named-only target is not.
1403        assert!(graph.has_namespace_import(FileId(1)));
1404        assert!(!graph.has_namespace_import(FileId(2)));
1405
1406        // Simulate the cache load: the bitset arrives empty (serde-skipped), then
1407        // the loader reconstructs it from the persisted edges.
1408        graph.namespace_imported = FixedBitSet::default();
1409        graph.reconstruct_namespace_imported();
1410
1411        assert_eq!(
1412            graph.namespace_imported, fresh,
1413            "reconstructed namespace_imported must equal the fresh-built bitset"
1414        );
1415        assert!(graph.has_namespace_import(FileId(1)));
1416        assert!(!graph.has_namespace_import(FileId(2)));
1417    }
1418
1419    #[test]
1420    fn graph_unreachable_module() {
1421        let files = vec![
1422            DiscoveredFile {
1423                id: FileId(0),
1424                path: PathBuf::from("/project/entry.ts"),
1425                size_bytes: 100,
1426            },
1427            DiscoveredFile {
1428                id: FileId(1),
1429                path: PathBuf::from("/project/utils.ts"),
1430                size_bytes: 50,
1431            },
1432            DiscoveredFile {
1433                id: FileId(2),
1434                path: PathBuf::from("/project/orphan.ts"),
1435                size_bytes: 30,
1436            },
1437        ];
1438
1439        let entry_points = vec![EntryPoint {
1440            path: PathBuf::from("/project/entry.ts"),
1441            source: EntryPointSource::PackageJsonMain,
1442        }];
1443
1444        let resolved_modules = vec![
1445            ResolvedModule {
1446                file_id: FileId(0),
1447                path: PathBuf::from("/project/entry.ts"),
1448                resolved_imports: vec![ResolvedImport {
1449                    info: ImportInfo {
1450                        source: "./utils".to_string(),
1451                        imported_name: ImportedName::Named("foo".to_string()),
1452                        local_name: "foo".to_string(),
1453                        is_type_only: false,
1454                        from_style: false,
1455                        span: oxc_span::Span::new(0, 10),
1456                        source_span: oxc_span::Span::default(),
1457                    },
1458                    target: ResolveResult::InternalModule(FileId(1)),
1459                }],
1460                ..Default::default()
1461            },
1462            ResolvedModule {
1463                file_id: FileId(1),
1464                path: PathBuf::from("/project/utils.ts"),
1465                exports: vec![fallow_types::extract::ExportInfo {
1466                    name: ExportName::Named("foo".to_string()),
1467                    local_name: Some("foo".to_string()),
1468                    is_type_only: false,
1469                    visibility: VisibilityTag::None,
1470                    expected_unused_reason: None,
1471                    span: oxc_span::Span::new(0, 20),
1472                    members: vec![],
1473                    is_side_effect_used: false,
1474                    super_class: None,
1475                }]
1476                .into(),
1477                ..Default::default()
1478            },
1479            ResolvedModule {
1480                file_id: FileId(2),
1481                path: PathBuf::from("/project/orphan.ts"),
1482                exports: vec![fallow_types::extract::ExportInfo {
1483                    name: ExportName::Named("orphan".to_string()),
1484                    local_name: Some("orphan".to_string()),
1485                    is_type_only: false,
1486                    visibility: VisibilityTag::None,
1487                    expected_unused_reason: None,
1488                    span: oxc_span::Span::new(0, 20),
1489                    members: vec![],
1490                    is_side_effect_used: false,
1491                    super_class: None,
1492                }]
1493                .into(),
1494                ..Default::default()
1495            },
1496        ];
1497
1498        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1499
1500        assert!(graph.modules[0].is_reachable(), "entry should be reachable");
1501        assert!(graph.modules[1].is_reachable(), "utils should be reachable");
1502        assert!(
1503            !graph.modules[2].is_reachable(),
1504            "orphan should NOT be reachable"
1505        );
1506    }
1507
1508    #[test]
1509    fn graph_package_usage_tracked() {
1510        let files = vec![DiscoveredFile {
1511            id: FileId(0),
1512            path: PathBuf::from("/project/entry.ts"),
1513            size_bytes: 100,
1514        }];
1515
1516        let entry_points = vec![EntryPoint {
1517            path: PathBuf::from("/project/entry.ts"),
1518            source: EntryPointSource::PackageJsonMain,
1519        }];
1520
1521        let resolved_modules = vec![ResolvedModule {
1522            file_id: FileId(0),
1523            path: PathBuf::from("/project/entry.ts"),
1524            exports: vec![].into(),
1525            re_exports: vec![],
1526            resolved_imports: vec![
1527                ResolvedImport {
1528                    info: ImportInfo {
1529                        source: "react".to_string(),
1530                        imported_name: ImportedName::Default,
1531                        local_name: "React".to_string(),
1532                        is_type_only: false,
1533                        from_style: false,
1534                        span: oxc_span::Span::new(0, 10),
1535                        source_span: oxc_span::Span::default(),
1536                    },
1537                    target: ResolveResult::NpmPackage("react".to_string()),
1538                },
1539                ResolvedImport {
1540                    info: ImportInfo {
1541                        source: "lodash".to_string(),
1542                        imported_name: ImportedName::Named("merge".to_string()),
1543                        local_name: "merge".to_string(),
1544                        is_type_only: false,
1545                        from_style: false,
1546                        span: oxc_span::Span::new(15, 30),
1547                        source_span: oxc_span::Span::default(),
1548                    },
1549                    target: ResolveResult::NpmPackage("lodash".to_string()),
1550                },
1551            ],
1552            ..Default::default()
1553        }];
1554
1555        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1556        assert!(graph.package_usage.contains_key("react"));
1557        assert!(graph.package_usage.contains_key("lodash"));
1558        assert!(!graph.package_usage.contains_key("express"));
1559    }
1560
1561    #[test]
1562    fn graph_empty() {
1563        let graph = ModuleGraph::build(&[], &[], &[]);
1564        assert_eq!(graph.module_count(), 0);
1565        assert_eq!(graph.edge_count(), 0);
1566    }
1567
1568    /// The persisted graph cache postcard-encodes the whole `ModuleGraph` and
1569    /// decodes it on a warm run. This proves the serde round-trip is lossless
1570    /// for the structural surface analysis reads: module / edge / export /
1571    /// reference counts and the `namespace_imported` bitset (reconstructed on
1572    /// load) all survive.
1573    #[test]
1574    fn graph_postcard_round_trip_is_lossless() {
1575        let graph = build_simple_graph();
1576
1577        let encoded = postcard::to_allocvec(&graph).expect("encode graph");
1578        let mut decoded: ModuleGraph = postcard::from_bytes(&encoded).expect("decode graph");
1579        // The store does this on load; do it here so the bitset is restored.
1580        decoded.reconstruct_namespace_imported();
1581
1582        assert_eq!(decoded.module_count(), graph.module_count());
1583        assert_eq!(decoded.edge_count(), graph.edge_count());
1584        assert_eq!(decoded.namespace_imported, graph.namespace_imported);
1585
1586        // Export + reference + member surface survives byte-for-byte.
1587        let utils = &decoded.modules[1];
1588        let foo = utils
1589            .exports
1590            .iter()
1591            .find(|e| e.name.to_string() == "foo")
1592            .expect("foo export survives round-trip");
1593        assert!(!foo.references.is_empty());
1594        let bar = utils
1595            .exports
1596            .iter()
1597            .find(|e| e.name.to_string() == "bar")
1598            .expect("bar export survives round-trip");
1599        assert!(bar.references.is_empty());
1600
1601        // Reachability flags and entry-point sets survive.
1602        assert!(decoded.modules[0].is_entry_point());
1603        assert!(decoded.modules[0].is_reachable());
1604        assert!(decoded.modules[1].is_reachable());
1605        assert_eq!(decoded.entry_points, graph.entry_points);
1606    }
1607
1608    #[test]
1609    fn graph_cjs_exports_tracked() {
1610        let files = vec![DiscoveredFile {
1611            id: FileId(0),
1612            path: PathBuf::from("/project/entry.ts"),
1613            size_bytes: 100,
1614        }];
1615
1616        let entry_points = vec![EntryPoint {
1617            path: PathBuf::from("/project/entry.ts"),
1618            source: EntryPointSource::PackageJsonMain,
1619        }];
1620
1621        let resolved_modules = vec![ResolvedModule {
1622            file_id: FileId(0),
1623            path: PathBuf::from("/project/entry.ts"),
1624            has_cjs_exports: true,
1625            has_angular_component_template_url: false,
1626            ..Default::default()
1627        }];
1628
1629        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1630        assert!(graph.modules[0].has_cjs_exports());
1631    }
1632
1633    #[test]
1634    fn graph_edges_for_returns_targets() {
1635        let graph = build_simple_graph();
1636        let targets = graph.edges_for(FileId(0));
1637        assert_eq!(targets, vec![FileId(1)]);
1638    }
1639
1640    #[test]
1641    fn graph_edges_for_no_imports() {
1642        let graph = build_simple_graph();
1643        let targets = graph.edges_for(FileId(1));
1644        assert!(targets.is_empty());
1645    }
1646
1647    #[test]
1648    fn graph_edges_for_out_of_bounds() {
1649        let graph = build_simple_graph();
1650        let targets = graph.edges_for(FileId(999));
1651        assert!(targets.is_empty());
1652    }
1653
1654    #[test]
1655    fn graph_direct_importer_summaries_include_symbols() {
1656        let graph = build_simple_graph();
1657        let summaries = graph.direct_importer_summaries(FileId(1));
1658
1659        assert_eq!(
1660            summaries,
1661            vec![DirectImporterSummary {
1662                source: FileId(0),
1663                symbols: vec![ImportedSymbolSummary {
1664                    imported: "foo".to_string(),
1665                    local: "foo".to_string(),
1666                    type_only: false,
1667                }],
1668            }]
1669        );
1670    }
1671
1672    #[test]
1673    fn graph_find_import_span_start_found() {
1674        let graph = build_simple_graph();
1675        let span_start = graph.find_import_span_start(FileId(0), FileId(1));
1676        assert!(span_start.is_some());
1677        assert_eq!(span_start.unwrap(), 0);
1678    }
1679
1680    #[test]
1681    fn graph_find_import_span_start_prefers_value_import_on_mixed_edge() {
1682        let files = vec![
1683            DiscoveredFile {
1684                id: FileId(0),
1685                path: PathBuf::from("/project/entry.ts"),
1686                size_bytes: 100,
1687            },
1688            DiscoveredFile {
1689                id: FileId(1),
1690                path: PathBuf::from("/project/utils.ts"),
1691                size_bytes: 50,
1692            },
1693        ];
1694        let entry_points = vec![EntryPoint {
1695            path: PathBuf::from("/project/entry.ts"),
1696            source: EntryPointSource::PackageJsonMain,
1697        }];
1698        let resolved_modules = vec![
1699            ResolvedModule {
1700                file_id: FileId(0),
1701                path: PathBuf::from("/project/entry.ts"),
1702                resolved_imports: vec![
1703                    ResolvedImport {
1704                        info: ImportInfo {
1705                            source: "./utils".to_string(),
1706                            imported_name: ImportedName::Named("Foo".to_string()),
1707                            local_name: "Foo".to_string(),
1708                            is_type_only: true,
1709                            from_style: false,
1710                            span: oxc_span::Span::new(10, 20),
1711                            source_span: oxc_span::Span::default(),
1712                        },
1713                        target: ResolveResult::InternalModule(FileId(1)),
1714                    },
1715                    ResolvedImport {
1716                        info: ImportInfo {
1717                            source: "./utils".to_string(),
1718                            imported_name: ImportedName::Named("foo".to_string()),
1719                            local_name: "foo".to_string(),
1720                            is_type_only: false,
1721                            from_style: false,
1722                            span: oxc_span::Span::new(50, 60),
1723                            source_span: oxc_span::Span::default(),
1724                        },
1725                        target: ResolveResult::InternalModule(FileId(1)),
1726                    },
1727                ],
1728                ..Default::default()
1729            },
1730            ResolvedModule {
1731                file_id: FileId(1),
1732                path: PathBuf::from("/project/utils.ts"),
1733                ..Default::default()
1734            },
1735        ];
1736
1737        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1738        assert_eq!(graph.find_import_span_start(FileId(0), FileId(1)), Some(50));
1739    }
1740
1741    #[test]
1742    fn graph_find_import_span_start_wrong_target() {
1743        let graph = build_simple_graph();
1744        let span_start = graph.find_import_span_start(FileId(0), FileId(0));
1745        assert!(span_start.is_none());
1746    }
1747
1748    #[test]
1749    fn graph_find_import_span_start_source_out_of_bounds() {
1750        let graph = build_simple_graph();
1751        let span_start = graph.find_import_span_start(FileId(999), FileId(1));
1752        assert!(span_start.is_none());
1753    }
1754
1755    #[test]
1756    fn graph_find_import_span_start_no_edges() {
1757        let graph = build_simple_graph();
1758        let span_start = graph.find_import_span_start(FileId(1), FileId(0));
1759        assert!(span_start.is_none());
1760    }
1761
1762    #[test]
1763    fn graph_reverse_deps_populated() {
1764        let graph = build_simple_graph();
1765        assert!(graph.reverse_deps[1].contains(&FileId(0)));
1766        assert!(graph.reverse_deps[0].is_empty());
1767    }
1768
1769    #[test]
1770    fn graph_type_only_package_usage_tracked() {
1771        let files = vec![DiscoveredFile {
1772            id: FileId(0),
1773            path: PathBuf::from("/project/entry.ts"),
1774            size_bytes: 100,
1775        }];
1776        let entry_points = vec![EntryPoint {
1777            path: PathBuf::from("/project/entry.ts"),
1778            source: EntryPointSource::PackageJsonMain,
1779        }];
1780        let resolved_modules = vec![ResolvedModule {
1781            file_id: FileId(0),
1782            path: PathBuf::from("/project/entry.ts"),
1783            resolved_imports: vec![
1784                ResolvedImport {
1785                    info: ImportInfo {
1786                        source: "react".to_string(),
1787                        imported_name: ImportedName::Named("FC".to_string()),
1788                        local_name: "FC".to_string(),
1789                        is_type_only: true,
1790                        from_style: false,
1791                        span: oxc_span::Span::new(0, 10),
1792                        source_span: oxc_span::Span::default(),
1793                    },
1794                    target: ResolveResult::NpmPackage("react".to_string()),
1795                },
1796                ResolvedImport {
1797                    info: ImportInfo {
1798                        source: "react".to_string(),
1799                        imported_name: ImportedName::Named("useState".to_string()),
1800                        local_name: "useState".to_string(),
1801                        is_type_only: false,
1802                        from_style: false,
1803                        span: oxc_span::Span::new(15, 30),
1804                        source_span: oxc_span::Span::default(),
1805                    },
1806                    target: ResolveResult::NpmPackage("react".to_string()),
1807                },
1808            ],
1809            ..Default::default()
1810        }];
1811
1812        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1813        assert!(graph.package_usage.contains_key("react"));
1814        assert!(graph.type_only_package_usage.contains_key("react"));
1815    }
1816
1817    #[test]
1818    fn graph_default_import_reference() {
1819        let files = vec![
1820            DiscoveredFile {
1821                id: FileId(0),
1822                path: PathBuf::from("/project/entry.ts"),
1823                size_bytes: 100,
1824            },
1825            DiscoveredFile {
1826                id: FileId(1),
1827                path: PathBuf::from("/project/utils.ts"),
1828                size_bytes: 50,
1829            },
1830        ];
1831        let entry_points = vec![EntryPoint {
1832            path: PathBuf::from("/project/entry.ts"),
1833            source: EntryPointSource::PackageJsonMain,
1834        }];
1835        let resolved_modules = vec![
1836            ResolvedModule {
1837                file_id: FileId(0),
1838                path: PathBuf::from("/project/entry.ts"),
1839                resolved_imports: vec![ResolvedImport {
1840                    info: ImportInfo {
1841                        source: "./utils".to_string(),
1842                        imported_name: ImportedName::Default,
1843                        local_name: "Utils".to_string(),
1844                        is_type_only: false,
1845                        from_style: false,
1846                        span: oxc_span::Span::new(0, 10),
1847                        source_span: oxc_span::Span::default(),
1848                    },
1849                    target: ResolveResult::InternalModule(FileId(1)),
1850                }],
1851                ..Default::default()
1852            },
1853            ResolvedModule {
1854                file_id: FileId(1),
1855                path: PathBuf::from("/project/utils.ts"),
1856                exports: vec![fallow_types::extract::ExportInfo {
1857                    name: ExportName::Default,
1858                    local_name: None,
1859                    is_type_only: false,
1860                    visibility: VisibilityTag::None,
1861                    expected_unused_reason: None,
1862                    span: oxc_span::Span::new(0, 20),
1863                    members: vec![],
1864                    is_side_effect_used: false,
1865                    super_class: None,
1866                }]
1867                .into(),
1868                ..Default::default()
1869            },
1870        ];
1871
1872        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1873        let utils = &graph.modules[1];
1874        let default_export = utils
1875            .exports
1876            .iter()
1877            .find(|e| matches!(e.name, ExportName::Default))
1878            .unwrap();
1879        assert!(!default_export.references.is_empty());
1880        assert_eq!(
1881            default_export.references[0].kind,
1882            ReferenceKind::DefaultImport
1883        );
1884    }
1885
1886    #[test]
1887    fn graph_side_effect_import_no_export_reference() {
1888        let files = vec![
1889            DiscoveredFile {
1890                id: FileId(0),
1891                path: PathBuf::from("/project/entry.ts"),
1892                size_bytes: 100,
1893            },
1894            DiscoveredFile {
1895                id: FileId(1),
1896                path: PathBuf::from("/project/styles.ts"),
1897                size_bytes: 50,
1898            },
1899        ];
1900        let entry_points = vec![EntryPoint {
1901            path: PathBuf::from("/project/entry.ts"),
1902            source: EntryPointSource::PackageJsonMain,
1903        }];
1904        let resolved_modules = vec![
1905            ResolvedModule {
1906                file_id: FileId(0),
1907                path: PathBuf::from("/project/entry.ts"),
1908                resolved_imports: vec![ResolvedImport {
1909                    info: ImportInfo {
1910                        source: "./styles".to_string(),
1911                        imported_name: ImportedName::SideEffect,
1912                        local_name: String::new(),
1913                        is_type_only: false,
1914                        from_style: false,
1915                        span: oxc_span::Span::new(0, 10),
1916                        source_span: oxc_span::Span::default(),
1917                    },
1918                    target: ResolveResult::InternalModule(FileId(1)),
1919                }],
1920                ..Default::default()
1921            },
1922            ResolvedModule {
1923                file_id: FileId(1),
1924                path: PathBuf::from("/project/styles.ts"),
1925                exports: vec![fallow_types::extract::ExportInfo {
1926                    name: ExportName::Named("primaryColor".to_string()),
1927                    local_name: Some("primaryColor".to_string()),
1928                    is_type_only: false,
1929                    visibility: VisibilityTag::None,
1930                    expected_unused_reason: None,
1931                    span: oxc_span::Span::new(0, 20),
1932                    members: vec![],
1933                    is_side_effect_used: false,
1934                    super_class: None,
1935                }]
1936                .into(),
1937                ..Default::default()
1938            },
1939        ];
1940
1941        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1942        assert_eq!(graph.edge_count(), 1);
1943        let styles = &graph.modules[1];
1944        let export = &styles.exports[0];
1945        assert!(
1946            export.references.is_empty(),
1947            "side-effect import should not reference named exports"
1948        );
1949    }
1950
1951    #[test]
1952    fn graph_multiple_entry_points() {
1953        let files = vec![
1954            DiscoveredFile {
1955                id: FileId(0),
1956                path: PathBuf::from("/project/main.ts"),
1957                size_bytes: 100,
1958            },
1959            DiscoveredFile {
1960                id: FileId(1),
1961                path: PathBuf::from("/project/worker.ts"),
1962                size_bytes: 100,
1963            },
1964            DiscoveredFile {
1965                id: FileId(2),
1966                path: PathBuf::from("/project/shared.ts"),
1967                size_bytes: 50,
1968            },
1969        ];
1970        let entry_points = vec![
1971            EntryPoint {
1972                path: PathBuf::from("/project/main.ts"),
1973                source: EntryPointSource::PackageJsonMain,
1974            },
1975            EntryPoint {
1976                path: PathBuf::from("/project/worker.ts"),
1977                source: EntryPointSource::PackageJsonMain,
1978            },
1979        ];
1980        let resolved_modules = vec![
1981            ResolvedModule {
1982                file_id: FileId(0),
1983                path: PathBuf::from("/project/main.ts"),
1984                resolved_imports: vec![ResolvedImport {
1985                    info: ImportInfo {
1986                        source: "./shared".to_string(),
1987                        imported_name: ImportedName::Named("helper".to_string()),
1988                        local_name: "helper".to_string(),
1989                        is_type_only: false,
1990                        from_style: false,
1991                        span: oxc_span::Span::new(0, 10),
1992                        source_span: oxc_span::Span::default(),
1993                    },
1994                    target: ResolveResult::InternalModule(FileId(2)),
1995                }],
1996                ..Default::default()
1997            },
1998            ResolvedModule {
1999                file_id: FileId(1),
2000                path: PathBuf::from("/project/worker.ts"),
2001                ..Default::default()
2002            },
2003            ResolvedModule {
2004                file_id: FileId(2),
2005                path: PathBuf::from("/project/shared.ts"),
2006                exports: vec![fallow_types::extract::ExportInfo {
2007                    name: ExportName::Named("helper".to_string()),
2008                    local_name: Some("helper".to_string()),
2009                    is_type_only: false,
2010                    visibility: VisibilityTag::None,
2011                    expected_unused_reason: None,
2012                    span: oxc_span::Span::new(0, 20),
2013                    members: vec![],
2014                    is_side_effect_used: false,
2015                    super_class: None,
2016                }]
2017                .into(),
2018                ..Default::default()
2019            },
2020        ];
2021
2022        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2023        assert!(graph.modules[0].is_entry_point());
2024        assert!(graph.modules[1].is_entry_point());
2025        assert!(!graph.modules[2].is_entry_point());
2026        assert!(graph.modules[0].is_reachable());
2027        assert!(graph.modules[1].is_reachable());
2028        assert!(graph.modules[2].is_reachable());
2029    }
2030}