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