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