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 re_exports;
9mod reachability;
10pub mod types;
11
12use std::path::PathBuf;
13
14use fixedbitset::FixedBitSet;
15use rustc_hash::{FxHashMap, FxHashSet};
16
17use crate::resolve::ResolvedModule;
18use fallow_types::discover::{DiscoveredFile, EntryPoint, FileId};
19use fallow_types::extract::ImportedName;
20
21// Re-export all public types so downstream sees the same API as before.
22pub use types::{ExportSymbol, ModuleNode, ReExportEdge, ReferenceKind, SymbolReference};
23
24/// The core module dependency graph.
25#[derive(Debug)]
26pub struct ModuleGraph {
27    /// All modules indexed by `FileId`.
28    pub modules: Vec<ModuleNode>,
29    /// Flat edge storage for cache-friendly iteration.
30    edges: Vec<Edge>,
31    /// Maps npm package names to the set of `FileId`s that import them.
32    pub package_usage: FxHashMap<String, Vec<FileId>>,
33    /// Maps npm package names to the set of `FileId`s that import them with type-only imports.
34    /// A package appearing here but not in `package_usage` (or only in both) indicates
35    /// it's only used for types and could be a devDependency.
36    pub type_only_package_usage: FxHashMap<String, Vec<FileId>>,
37    /// All entry point `FileId`s.
38    pub entry_points: FxHashSet<FileId>,
39    /// Reverse index: for each `FileId`, which files import it.
40    pub reverse_deps: Vec<Vec<FileId>>,
41    /// Precomputed: which modules have namespace imports (import * as ns).
42    namespace_imported: FixedBitSet,
43}
44
45/// An edge in the module graph.
46#[derive(Debug)]
47pub(super) struct Edge {
48    pub(super) source: FileId,
49    pub(super) target: FileId,
50    pub(super) symbols: Vec<ImportedSymbol>,
51}
52
53/// A symbol imported across an edge.
54#[derive(Debug)]
55pub(super) struct ImportedSymbol {
56    pub(super) imported_name: ImportedName,
57    pub(super) local_name: String,
58    /// Byte span of the import statement in the source file.
59    pub(super) import_span: oxc_span::Span,
60}
61
62// Size assertions to prevent memory regressions in hot-path graph types.
63// `Edge` is stored in a flat contiguous Vec for cache-friendly traversal.
64// `ImportedSymbol` is stored in a Vec per Edge.
65#[cfg(target_pointer_width = "64")]
66const _: () = assert!(std::mem::size_of::<Edge>() == 32);
67#[cfg(target_pointer_width = "64")]
68const _: () = assert!(std::mem::size_of::<ImportedSymbol>() == 56);
69
70impl ModuleGraph {
71    /// Build the module graph from resolved modules and entry points.
72    pub fn build(
73        resolved_modules: &[ResolvedModule],
74        entry_points: &[EntryPoint],
75        files: &[DiscoveredFile],
76    ) -> Self {
77        let _span = tracing::info_span!("build_graph").entered();
78
79        let module_count = files.len();
80
81        // Compute the total capacity needed, accounting for workspace FileIds
82        // that may exceed files.len() if IDs are assigned beyond the file count.
83        let max_file_id = files
84            .iter()
85            .map(|f| f.id.0 as usize)
86            .max()
87            .map_or(0, |m| m + 1);
88        let total_capacity = max_file_id.max(module_count);
89
90        // Build path -> FileId index
91        let path_to_id: FxHashMap<PathBuf, FileId> =
92            files.iter().map(|f| (f.path.clone(), f.id)).collect();
93
94        // Build FileId -> ResolvedModule index
95        let module_by_id: FxHashMap<FileId, &ResolvedModule> =
96            resolved_modules.iter().map(|m| (m.file_id, m)).collect();
97
98        // Build entry point set — use path_to_id map instead of O(n) scan per entry
99        let entry_point_ids: FxHashSet<FileId> = entry_points
100            .iter()
101            .filter_map(|ep| {
102                // Try direct lookup first (fast path)
103                path_to_id.get(&ep.path).copied().or_else(|| {
104                    // Fallback: canonicalize entry point and do a direct FxHashMap lookup
105                    ep.path
106                        .canonicalize()
107                        .ok()
108                        .and_then(|c| path_to_id.get(&c).copied())
109                })
110            })
111            .collect();
112
113        // Phase 1: Build flat edge storage, module nodes, and package usage from resolved modules
114        let mut graph = Self::populate_edges(
115            files,
116            &module_by_id,
117            &entry_point_ids,
118            module_count,
119            total_capacity,
120        );
121
122        // Phase 2: Record which files reference which exports (namespace + CSS module narrowing)
123        graph.populate_references(&module_by_id, &entry_point_ids);
124
125        // Phase 3: BFS from entry points to mark reachable modules
126        graph.mark_reachable(total_capacity);
127
128        // Phase 4: Propagate references through re-export chains
129        graph.resolve_re_export_chains();
130
131        graph
132    }
133
134    /// Total number of modules.
135    #[must_use]
136    pub const fn module_count(&self) -> usize {
137        self.modules.len()
138    }
139
140    /// Total number of edges.
141    #[must_use]
142    pub const fn edge_count(&self) -> usize {
143        self.edges.len()
144    }
145
146    /// Check if any importer uses `import * as ns` for this module.
147    /// Uses precomputed bitset — O(1) lookup.
148    #[must_use]
149    pub fn has_namespace_import(&self, file_id: FileId) -> bool {
150        let idx = file_id.0 as usize;
151        if idx >= self.namespace_imported.len() {
152            return false;
153        }
154        self.namespace_imported.contains(idx)
155    }
156
157    /// Get the target `FileId`s of all outgoing edges for a module.
158    #[must_use]
159    pub fn edges_for(&self, file_id: FileId) -> Vec<FileId> {
160        let idx = file_id.0 as usize;
161        if idx >= self.modules.len() {
162            return Vec::new();
163        }
164        let range = &self.modules[idx].edge_range;
165        self.edges[range.clone()].iter().map(|e| e.target).collect()
166    }
167
168    /// Find the byte offset of the first import statement from `source` to `target`.
169    /// Returns `None` if no edge exists or the edge has no symbols.
170    #[must_use]
171    pub fn find_import_span_start(&self, source: FileId, target: FileId) -> Option<u32> {
172        let idx = source.0 as usize;
173        if idx >= self.modules.len() {
174            return None;
175        }
176        let range = &self.modules[idx].edge_range;
177        for edge in &self.edges[range.clone()] {
178            if edge.target == target {
179                return edge.symbols.first().map(|s| s.import_span.start);
180            }
181        }
182        None
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
190    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
191    use fallow_types::extract::{ExportName, ImportInfo, ImportedName};
192    use std::path::PathBuf;
193
194    // Helper to build a simple module graph
195    fn build_simple_graph() -> ModuleGraph {
196        // Two files: entry.ts imports foo from utils.ts
197        let files = vec![
198            DiscoveredFile {
199                id: FileId(0),
200                path: PathBuf::from("/project/src/entry.ts"),
201                size_bytes: 100,
202            },
203            DiscoveredFile {
204                id: FileId(1),
205                path: PathBuf::from("/project/src/utils.ts"),
206                size_bytes: 50,
207            },
208        ];
209
210        let entry_points = vec![EntryPoint {
211            path: PathBuf::from("/project/src/entry.ts"),
212            source: EntryPointSource::PackageJsonMain,
213        }];
214
215        let resolved_modules = vec![
216            ResolvedModule {
217                file_id: FileId(0),
218                path: PathBuf::from("/project/src/entry.ts"),
219                exports: vec![],
220                re_exports: vec![],
221                resolved_imports: vec![ResolvedImport {
222                    info: ImportInfo {
223                        source: "./utils".to_string(),
224                        imported_name: ImportedName::Named("foo".to_string()),
225                        local_name: "foo".to_string(),
226                        is_type_only: false,
227                        span: oxc_span::Span::new(0, 10),
228                        source_span: oxc_span::Span::default(),
229                    },
230                    target: ResolveResult::InternalModule(FileId(1)),
231                }],
232                resolved_dynamic_imports: vec![],
233                resolved_dynamic_patterns: vec![],
234                member_accesses: vec![],
235                whole_object_uses: vec![],
236                has_cjs_exports: false,
237                unused_import_bindings: FxHashSet::default(),
238            },
239            ResolvedModule {
240                file_id: FileId(1),
241                path: PathBuf::from("/project/src/utils.ts"),
242                exports: vec![
243                    fallow_types::extract::ExportInfo {
244                        name: ExportName::Named("foo".to_string()),
245                        local_name: Some("foo".to_string()),
246                        is_type_only: false,
247                        is_public: false,
248                        span: oxc_span::Span::new(0, 20),
249                        members: vec![],
250                    },
251                    fallow_types::extract::ExportInfo {
252                        name: ExportName::Named("bar".to_string()),
253                        local_name: Some("bar".to_string()),
254                        is_type_only: false,
255                        is_public: false,
256                        span: oxc_span::Span::new(25, 45),
257                        members: vec![],
258                    },
259                ],
260                re_exports: vec![],
261                resolved_imports: vec![],
262                resolved_dynamic_imports: vec![],
263                resolved_dynamic_patterns: vec![],
264                member_accesses: vec![],
265                whole_object_uses: vec![],
266                has_cjs_exports: false,
267                unused_import_bindings: FxHashSet::default(),
268            },
269        ];
270
271        ModuleGraph::build(&resolved_modules, &entry_points, &files)
272    }
273
274    #[test]
275    fn graph_module_count() {
276        let graph = build_simple_graph();
277        assert_eq!(graph.module_count(), 2);
278    }
279
280    #[test]
281    fn graph_edge_count() {
282        let graph = build_simple_graph();
283        assert_eq!(graph.edge_count(), 1);
284    }
285
286    #[test]
287    fn graph_entry_point_is_reachable() {
288        let graph = build_simple_graph();
289        assert!(graph.modules[0].is_entry_point);
290        assert!(graph.modules[0].is_reachable);
291    }
292
293    #[test]
294    fn graph_imported_module_is_reachable() {
295        let graph = build_simple_graph();
296        assert!(!graph.modules[1].is_entry_point);
297        assert!(graph.modules[1].is_reachable);
298    }
299
300    #[test]
301    fn graph_export_has_reference() {
302        let graph = build_simple_graph();
303        let utils = &graph.modules[1];
304        let foo_export = utils
305            .exports
306            .iter()
307            .find(|e| e.name.to_string() == "foo")
308            .unwrap();
309        assert!(
310            !foo_export.references.is_empty(),
311            "foo should have references"
312        );
313    }
314
315    #[test]
316    fn graph_unused_export_no_reference() {
317        let graph = build_simple_graph();
318        let utils = &graph.modules[1];
319        let bar_export = utils
320            .exports
321            .iter()
322            .find(|e| e.name.to_string() == "bar")
323            .unwrap();
324        assert!(
325            bar_export.references.is_empty(),
326            "bar should have no references"
327        );
328    }
329
330    #[test]
331    fn graph_no_namespace_import() {
332        let graph = build_simple_graph();
333        assert!(!graph.has_namespace_import(FileId(0)));
334        assert!(!graph.has_namespace_import(FileId(1)));
335    }
336
337    #[test]
338    fn graph_has_namespace_import() {
339        let files = vec![
340            DiscoveredFile {
341                id: FileId(0),
342                path: PathBuf::from("/project/entry.ts"),
343                size_bytes: 100,
344            },
345            DiscoveredFile {
346                id: FileId(1),
347                path: PathBuf::from("/project/utils.ts"),
348                size_bytes: 50,
349            },
350        ];
351
352        let entry_points = vec![EntryPoint {
353            path: PathBuf::from("/project/entry.ts"),
354            source: EntryPointSource::PackageJsonMain,
355        }];
356
357        let resolved_modules = vec![
358            ResolvedModule {
359                file_id: FileId(0),
360                path: PathBuf::from("/project/entry.ts"),
361                exports: vec![],
362                re_exports: vec![],
363                resolved_imports: vec![ResolvedImport {
364                    info: ImportInfo {
365                        source: "./utils".to_string(),
366                        imported_name: ImportedName::Namespace,
367                        local_name: "utils".to_string(),
368                        is_type_only: false,
369                        span: oxc_span::Span::new(0, 10),
370                        source_span: oxc_span::Span::default(),
371                    },
372                    target: ResolveResult::InternalModule(FileId(1)),
373                }],
374                resolved_dynamic_imports: vec![],
375                resolved_dynamic_patterns: vec![],
376                member_accesses: vec![],
377                whole_object_uses: vec![],
378                has_cjs_exports: false,
379                unused_import_bindings: FxHashSet::default(),
380            },
381            ResolvedModule {
382                file_id: FileId(1),
383                path: PathBuf::from("/project/utils.ts"),
384                exports: vec![fallow_types::extract::ExportInfo {
385                    name: ExportName::Named("foo".to_string()),
386                    local_name: Some("foo".to_string()),
387                    is_type_only: false,
388                    is_public: false,
389                    span: oxc_span::Span::new(0, 20),
390                    members: vec![],
391                }],
392                re_exports: vec![],
393                resolved_imports: vec![],
394                resolved_dynamic_imports: vec![],
395                resolved_dynamic_patterns: vec![],
396                member_accesses: vec![],
397                whole_object_uses: vec![],
398                has_cjs_exports: false,
399                unused_import_bindings: FxHashSet::default(),
400            },
401        ];
402
403        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
404        assert!(
405            graph.has_namespace_import(FileId(1)),
406            "utils should have namespace import"
407        );
408    }
409
410    #[test]
411    fn graph_has_namespace_import_out_of_bounds() {
412        let graph = build_simple_graph();
413        assert!(!graph.has_namespace_import(FileId(999)));
414    }
415
416    #[test]
417    fn graph_unreachable_module() {
418        // Three files: entry imports utils, orphan is not imported
419        let files = vec![
420            DiscoveredFile {
421                id: FileId(0),
422                path: PathBuf::from("/project/entry.ts"),
423                size_bytes: 100,
424            },
425            DiscoveredFile {
426                id: FileId(1),
427                path: PathBuf::from("/project/utils.ts"),
428                size_bytes: 50,
429            },
430            DiscoveredFile {
431                id: FileId(2),
432                path: PathBuf::from("/project/orphan.ts"),
433                size_bytes: 30,
434            },
435        ];
436
437        let entry_points = vec![EntryPoint {
438            path: PathBuf::from("/project/entry.ts"),
439            source: EntryPointSource::PackageJsonMain,
440        }];
441
442        let resolved_modules = vec![
443            ResolvedModule {
444                file_id: FileId(0),
445                path: PathBuf::from("/project/entry.ts"),
446                exports: vec![],
447                re_exports: vec![],
448                resolved_imports: vec![ResolvedImport {
449                    info: ImportInfo {
450                        source: "./utils".to_string(),
451                        imported_name: ImportedName::Named("foo".to_string()),
452                        local_name: "foo".to_string(),
453                        is_type_only: false,
454                        span: oxc_span::Span::new(0, 10),
455                        source_span: oxc_span::Span::default(),
456                    },
457                    target: ResolveResult::InternalModule(FileId(1)),
458                }],
459                resolved_dynamic_imports: vec![],
460                resolved_dynamic_patterns: vec![],
461                member_accesses: vec![],
462                whole_object_uses: vec![],
463                has_cjs_exports: false,
464                unused_import_bindings: FxHashSet::default(),
465            },
466            ResolvedModule {
467                file_id: FileId(1),
468                path: PathBuf::from("/project/utils.ts"),
469                exports: vec![fallow_types::extract::ExportInfo {
470                    name: ExportName::Named("foo".to_string()),
471                    local_name: Some("foo".to_string()),
472                    is_type_only: false,
473                    is_public: false,
474                    span: oxc_span::Span::new(0, 20),
475                    members: vec![],
476                }],
477                re_exports: vec![],
478                resolved_imports: vec![],
479                resolved_dynamic_imports: vec![],
480                resolved_dynamic_patterns: vec![],
481                member_accesses: vec![],
482                whole_object_uses: vec![],
483                has_cjs_exports: false,
484                unused_import_bindings: FxHashSet::default(),
485            },
486            ResolvedModule {
487                file_id: FileId(2),
488                path: PathBuf::from("/project/orphan.ts"),
489                exports: vec![fallow_types::extract::ExportInfo {
490                    name: ExportName::Named("orphan".to_string()),
491                    local_name: Some("orphan".to_string()),
492                    is_type_only: false,
493                    is_public: false,
494                    span: oxc_span::Span::new(0, 20),
495                    members: vec![],
496                }],
497                re_exports: vec![],
498                resolved_imports: vec![],
499                resolved_dynamic_imports: vec![],
500                resolved_dynamic_patterns: vec![],
501                member_accesses: vec![],
502                whole_object_uses: vec![],
503                has_cjs_exports: false,
504                unused_import_bindings: FxHashSet::default(),
505            },
506        ];
507
508        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
509
510        assert!(graph.modules[0].is_reachable, "entry should be reachable");
511        assert!(graph.modules[1].is_reachable, "utils should be reachable");
512        assert!(
513            !graph.modules[2].is_reachable,
514            "orphan should NOT be reachable"
515        );
516    }
517
518    #[test]
519    fn graph_package_usage_tracked() {
520        let files = vec![DiscoveredFile {
521            id: FileId(0),
522            path: PathBuf::from("/project/entry.ts"),
523            size_bytes: 100,
524        }];
525
526        let entry_points = vec![EntryPoint {
527            path: PathBuf::from("/project/entry.ts"),
528            source: EntryPointSource::PackageJsonMain,
529        }];
530
531        let resolved_modules = vec![ResolvedModule {
532            file_id: FileId(0),
533            path: PathBuf::from("/project/entry.ts"),
534            exports: vec![],
535            re_exports: vec![],
536            resolved_imports: vec![
537                ResolvedImport {
538                    info: ImportInfo {
539                        source: "react".to_string(),
540                        imported_name: ImportedName::Default,
541                        local_name: "React".to_string(),
542                        is_type_only: false,
543                        span: oxc_span::Span::new(0, 10),
544                        source_span: oxc_span::Span::default(),
545                    },
546                    target: ResolveResult::NpmPackage("react".to_string()),
547                },
548                ResolvedImport {
549                    info: ImportInfo {
550                        source: "lodash".to_string(),
551                        imported_name: ImportedName::Named("merge".to_string()),
552                        local_name: "merge".to_string(),
553                        is_type_only: false,
554                        span: oxc_span::Span::new(15, 30),
555                        source_span: oxc_span::Span::default(),
556                    },
557                    target: ResolveResult::NpmPackage("lodash".to_string()),
558                },
559            ],
560            resolved_dynamic_imports: vec![],
561            resolved_dynamic_patterns: vec![],
562            member_accesses: vec![],
563            whole_object_uses: vec![],
564            has_cjs_exports: false,
565            unused_import_bindings: FxHashSet::default(),
566        }];
567
568        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
569        assert!(graph.package_usage.contains_key("react"));
570        assert!(graph.package_usage.contains_key("lodash"));
571        assert!(!graph.package_usage.contains_key("express"));
572    }
573
574    #[test]
575    fn graph_empty() {
576        let graph = ModuleGraph::build(&[], &[], &[]);
577        assert_eq!(graph.module_count(), 0);
578        assert_eq!(graph.edge_count(), 0);
579    }
580
581    #[test]
582    fn graph_cjs_exports_tracked() {
583        let files = vec![DiscoveredFile {
584            id: FileId(0),
585            path: PathBuf::from("/project/entry.ts"),
586            size_bytes: 100,
587        }];
588
589        let entry_points = vec![EntryPoint {
590            path: PathBuf::from("/project/entry.ts"),
591            source: EntryPointSource::PackageJsonMain,
592        }];
593
594        let resolved_modules = vec![ResolvedModule {
595            file_id: FileId(0),
596            path: PathBuf::from("/project/entry.ts"),
597            exports: vec![],
598            re_exports: vec![],
599            resolved_imports: vec![],
600            resolved_dynamic_imports: vec![],
601            resolved_dynamic_patterns: vec![],
602            member_accesses: vec![],
603            whole_object_uses: vec![],
604            has_cjs_exports: true,
605            unused_import_bindings: FxHashSet::default(),
606        }];
607
608        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
609        assert!(graph.modules[0].has_cjs_exports);
610    }
611
612    #[test]
613    fn graph_edges_for_returns_targets() {
614        let graph = build_simple_graph();
615        let targets = graph.edges_for(FileId(0));
616        assert_eq!(targets, vec![FileId(1)]);
617    }
618
619    #[test]
620    fn graph_edges_for_no_imports() {
621        let graph = build_simple_graph();
622        // utils.ts has no outgoing imports
623        let targets = graph.edges_for(FileId(1));
624        assert!(targets.is_empty());
625    }
626
627    #[test]
628    fn graph_edges_for_out_of_bounds() {
629        let graph = build_simple_graph();
630        let targets = graph.edges_for(FileId(999));
631        assert!(targets.is_empty());
632    }
633
634    #[test]
635    fn graph_find_import_span_start_found() {
636        let graph = build_simple_graph();
637        let span_start = graph.find_import_span_start(FileId(0), FileId(1));
638        assert!(span_start.is_some());
639        assert_eq!(span_start.unwrap(), 0);
640    }
641
642    #[test]
643    fn graph_find_import_span_start_wrong_target() {
644        let graph = build_simple_graph();
645        // No edge from entry.ts to itself
646        let span_start = graph.find_import_span_start(FileId(0), FileId(0));
647        assert!(span_start.is_none());
648    }
649
650    #[test]
651    fn graph_find_import_span_start_source_out_of_bounds() {
652        let graph = build_simple_graph();
653        let span_start = graph.find_import_span_start(FileId(999), FileId(1));
654        assert!(span_start.is_none());
655    }
656
657    #[test]
658    fn graph_find_import_span_start_no_edges() {
659        let graph = build_simple_graph();
660        // utils.ts has no outgoing edges
661        let span_start = graph.find_import_span_start(FileId(1), FileId(0));
662        assert!(span_start.is_none());
663    }
664
665    #[test]
666    fn graph_reverse_deps_populated() {
667        let graph = build_simple_graph();
668        // utils.ts (FileId(1)) should be imported by entry.ts (FileId(0))
669        assert!(graph.reverse_deps[1].contains(&FileId(0)));
670        // entry.ts (FileId(0)) should not be imported by anyone
671        assert!(graph.reverse_deps[0].is_empty());
672    }
673
674    #[test]
675    fn graph_type_only_package_usage_tracked() {
676        let files = vec![DiscoveredFile {
677            id: FileId(0),
678            path: PathBuf::from("/project/entry.ts"),
679            size_bytes: 100,
680        }];
681        let entry_points = vec![EntryPoint {
682            path: PathBuf::from("/project/entry.ts"),
683            source: EntryPointSource::PackageJsonMain,
684        }];
685        let resolved_modules = vec![ResolvedModule {
686            file_id: FileId(0),
687            path: PathBuf::from("/project/entry.ts"),
688            exports: vec![],
689            re_exports: vec![],
690            resolved_imports: vec![
691                ResolvedImport {
692                    info: ImportInfo {
693                        source: "react".to_string(),
694                        imported_name: ImportedName::Named("FC".to_string()),
695                        local_name: "FC".to_string(),
696                        is_type_only: true,
697                        span: oxc_span::Span::new(0, 10),
698                        source_span: oxc_span::Span::default(),
699                    },
700                    target: ResolveResult::NpmPackage("react".to_string()),
701                },
702                ResolvedImport {
703                    info: ImportInfo {
704                        source: "react".to_string(),
705                        imported_name: ImportedName::Named("useState".to_string()),
706                        local_name: "useState".to_string(),
707                        is_type_only: false,
708                        span: oxc_span::Span::new(15, 30),
709                        source_span: oxc_span::Span::default(),
710                    },
711                    target: ResolveResult::NpmPackage("react".to_string()),
712                },
713            ],
714            resolved_dynamic_imports: vec![],
715            resolved_dynamic_patterns: vec![],
716            member_accesses: vec![],
717            whole_object_uses: vec![],
718            has_cjs_exports: false,
719            unused_import_bindings: FxHashSet::default(),
720        }];
721
722        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
723        assert!(graph.package_usage.contains_key("react"));
724        assert!(graph.type_only_package_usage.contains_key("react"));
725    }
726
727    #[test]
728    fn graph_default_import_reference() {
729        let files = vec![
730            DiscoveredFile {
731                id: FileId(0),
732                path: PathBuf::from("/project/entry.ts"),
733                size_bytes: 100,
734            },
735            DiscoveredFile {
736                id: FileId(1),
737                path: PathBuf::from("/project/utils.ts"),
738                size_bytes: 50,
739            },
740        ];
741        let entry_points = vec![EntryPoint {
742            path: PathBuf::from("/project/entry.ts"),
743            source: EntryPointSource::PackageJsonMain,
744        }];
745        let resolved_modules = vec![
746            ResolvedModule {
747                file_id: FileId(0),
748                path: PathBuf::from("/project/entry.ts"),
749                exports: vec![],
750                re_exports: vec![],
751                resolved_imports: vec![ResolvedImport {
752                    info: ImportInfo {
753                        source: "./utils".to_string(),
754                        imported_name: ImportedName::Default,
755                        local_name: "Utils".to_string(),
756                        is_type_only: false,
757                        span: oxc_span::Span::new(0, 10),
758                        source_span: oxc_span::Span::default(),
759                    },
760                    target: ResolveResult::InternalModule(FileId(1)),
761                }],
762                resolved_dynamic_imports: vec![],
763                resolved_dynamic_patterns: vec![],
764                member_accesses: vec![],
765                whole_object_uses: vec![],
766                has_cjs_exports: false,
767                unused_import_bindings: FxHashSet::default(),
768            },
769            ResolvedModule {
770                file_id: FileId(1),
771                path: PathBuf::from("/project/utils.ts"),
772                exports: vec![fallow_types::extract::ExportInfo {
773                    name: ExportName::Default,
774                    local_name: None,
775                    is_type_only: false,
776                    is_public: false,
777                    span: oxc_span::Span::new(0, 20),
778                    members: vec![],
779                }],
780                re_exports: vec![],
781                resolved_imports: vec![],
782                resolved_dynamic_imports: vec![],
783                resolved_dynamic_patterns: vec![],
784                member_accesses: vec![],
785                whole_object_uses: vec![],
786                has_cjs_exports: false,
787                unused_import_bindings: FxHashSet::default(),
788            },
789        ];
790
791        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
792        let utils = &graph.modules[1];
793        let default_export = utils
794            .exports
795            .iter()
796            .find(|e| matches!(e.name, ExportName::Default))
797            .unwrap();
798        assert!(!default_export.references.is_empty());
799        assert_eq!(
800            default_export.references[0].kind,
801            ReferenceKind::DefaultImport
802        );
803    }
804
805    #[test]
806    fn graph_side_effect_import_no_export_reference() {
807        let files = vec![
808            DiscoveredFile {
809                id: FileId(0),
810                path: PathBuf::from("/project/entry.ts"),
811                size_bytes: 100,
812            },
813            DiscoveredFile {
814                id: FileId(1),
815                path: PathBuf::from("/project/styles.ts"),
816                size_bytes: 50,
817            },
818        ];
819        let entry_points = vec![EntryPoint {
820            path: PathBuf::from("/project/entry.ts"),
821            source: EntryPointSource::PackageJsonMain,
822        }];
823        let resolved_modules = vec![
824            ResolvedModule {
825                file_id: FileId(0),
826                path: PathBuf::from("/project/entry.ts"),
827                exports: vec![],
828                re_exports: vec![],
829                resolved_imports: vec![ResolvedImport {
830                    info: ImportInfo {
831                        source: "./styles".to_string(),
832                        imported_name: ImportedName::SideEffect,
833                        local_name: String::new(),
834                        is_type_only: false,
835                        span: oxc_span::Span::new(0, 10),
836                        source_span: oxc_span::Span::default(),
837                    },
838                    target: ResolveResult::InternalModule(FileId(1)),
839                }],
840                resolved_dynamic_imports: vec![],
841                resolved_dynamic_patterns: vec![],
842                member_accesses: vec![],
843                whole_object_uses: vec![],
844                has_cjs_exports: false,
845                unused_import_bindings: FxHashSet::default(),
846            },
847            ResolvedModule {
848                file_id: FileId(1),
849                path: PathBuf::from("/project/styles.ts"),
850                exports: vec![fallow_types::extract::ExportInfo {
851                    name: ExportName::Named("primaryColor".to_string()),
852                    local_name: Some("primaryColor".to_string()),
853                    is_type_only: false,
854                    is_public: false,
855                    span: oxc_span::Span::new(0, 20),
856                    members: vec![],
857                }],
858                re_exports: vec![],
859                resolved_imports: vec![],
860                resolved_dynamic_imports: vec![],
861                resolved_dynamic_patterns: vec![],
862                member_accesses: vec![],
863                whole_object_uses: vec![],
864                has_cjs_exports: false,
865                unused_import_bindings: FxHashSet::default(),
866            },
867        ];
868
869        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
870        // Side-effect import should create an edge but not reference specific exports
871        assert_eq!(graph.edge_count(), 1);
872        let styles = &graph.modules[1];
873        let export = &styles.exports[0];
874        // Side-effect import doesn't match any named export
875        assert!(
876            export.references.is_empty(),
877            "side-effect import should not reference named exports"
878        );
879    }
880
881    #[test]
882    fn graph_multiple_entry_points() {
883        let files = vec![
884            DiscoveredFile {
885                id: FileId(0),
886                path: PathBuf::from("/project/main.ts"),
887                size_bytes: 100,
888            },
889            DiscoveredFile {
890                id: FileId(1),
891                path: PathBuf::from("/project/worker.ts"),
892                size_bytes: 100,
893            },
894            DiscoveredFile {
895                id: FileId(2),
896                path: PathBuf::from("/project/shared.ts"),
897                size_bytes: 50,
898            },
899        ];
900        let entry_points = vec![
901            EntryPoint {
902                path: PathBuf::from("/project/main.ts"),
903                source: EntryPointSource::PackageJsonMain,
904            },
905            EntryPoint {
906                path: PathBuf::from("/project/worker.ts"),
907                source: EntryPointSource::PackageJsonMain,
908            },
909        ];
910        let resolved_modules = vec![
911            ResolvedModule {
912                file_id: FileId(0),
913                path: PathBuf::from("/project/main.ts"),
914                exports: vec![],
915                re_exports: vec![],
916                resolved_imports: vec![ResolvedImport {
917                    info: ImportInfo {
918                        source: "./shared".to_string(),
919                        imported_name: ImportedName::Named("helper".to_string()),
920                        local_name: "helper".to_string(),
921                        is_type_only: false,
922                        span: oxc_span::Span::new(0, 10),
923                        source_span: oxc_span::Span::default(),
924                    },
925                    target: ResolveResult::InternalModule(FileId(2)),
926                }],
927                resolved_dynamic_imports: vec![],
928                resolved_dynamic_patterns: vec![],
929                member_accesses: vec![],
930                whole_object_uses: vec![],
931                has_cjs_exports: false,
932                unused_import_bindings: FxHashSet::default(),
933            },
934            ResolvedModule {
935                file_id: FileId(1),
936                path: PathBuf::from("/project/worker.ts"),
937                exports: vec![],
938                re_exports: vec![],
939                resolved_imports: vec![],
940                resolved_dynamic_imports: vec![],
941                resolved_dynamic_patterns: vec![],
942                member_accesses: vec![],
943                whole_object_uses: vec![],
944                has_cjs_exports: false,
945                unused_import_bindings: FxHashSet::default(),
946            },
947            ResolvedModule {
948                file_id: FileId(2),
949                path: PathBuf::from("/project/shared.ts"),
950                exports: vec![fallow_types::extract::ExportInfo {
951                    name: ExportName::Named("helper".to_string()),
952                    local_name: Some("helper".to_string()),
953                    is_type_only: false,
954                    is_public: false,
955                    span: oxc_span::Span::new(0, 20),
956                    members: vec![],
957                }],
958                re_exports: vec![],
959                resolved_imports: vec![],
960                resolved_dynamic_imports: vec![],
961                resolved_dynamic_patterns: vec![],
962                member_accesses: vec![],
963                whole_object_uses: vec![],
964                has_cjs_exports: false,
965                unused_import_bindings: FxHashSet::default(),
966            },
967        ];
968
969        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
970        assert!(graph.modules[0].is_entry_point);
971        assert!(graph.modules[1].is_entry_point);
972        assert!(!graph.modules[2].is_entry_point);
973        // All should be reachable — shared is reached from main
974        assert!(graph.modules[0].is_reachable);
975        assert!(graph.modules[1].is_reachable);
976        assert!(graph.modules[2].is_reachable);
977    }
978}