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