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                        super_class: None,
283                    },
284                    fallow_types::extract::ExportInfo {
285                        name: ExportName::Named("bar".to_string()),
286                        local_name: Some("bar".to_string()),
287                        is_type_only: false,
288                        is_public: false,
289                        span: oxc_span::Span::new(25, 45),
290                        members: vec![],
291                        super_class: None,
292                    },
293                ],
294                ..Default::default()
295            },
296        ];
297
298        ModuleGraph::build(&resolved_modules, &entry_points, &files)
299    }
300
301    #[test]
302    fn graph_module_count() {
303        let graph = build_simple_graph();
304        assert_eq!(graph.module_count(), 2);
305    }
306
307    #[test]
308    fn graph_edge_count() {
309        let graph = build_simple_graph();
310        assert_eq!(graph.edge_count(), 1);
311    }
312
313    #[test]
314    fn graph_entry_point_is_reachable() {
315        let graph = build_simple_graph();
316        assert!(graph.modules[0].is_entry_point());
317        assert!(graph.modules[0].is_reachable());
318    }
319
320    #[test]
321    fn graph_imported_module_is_reachable() {
322        let graph = build_simple_graph();
323        assert!(!graph.modules[1].is_entry_point());
324        assert!(graph.modules[1].is_reachable());
325    }
326
327    #[test]
328    fn graph_distinguishes_runtime_test_and_support_reachability() {
329        let files = vec![
330            DiscoveredFile {
331                id: FileId(0),
332                path: PathBuf::from("/project/src/main.ts"),
333                size_bytes: 100,
334            },
335            DiscoveredFile {
336                id: FileId(1),
337                path: PathBuf::from("/project/src/runtime-only.ts"),
338                size_bytes: 50,
339            },
340            DiscoveredFile {
341                id: FileId(2),
342                path: PathBuf::from("/project/tests/app.test.ts"),
343                size_bytes: 50,
344            },
345            DiscoveredFile {
346                id: FileId(3),
347                path: PathBuf::from("/project/tests/setup.ts"),
348                size_bytes: 50,
349            },
350            DiscoveredFile {
351                id: FileId(4),
352                path: PathBuf::from("/project/src/covered.ts"),
353                size_bytes: 50,
354            },
355        ];
356
357        let all_entry_points = vec![
358            EntryPoint {
359                path: PathBuf::from("/project/src/main.ts"),
360                source: EntryPointSource::PackageJsonMain,
361            },
362            EntryPoint {
363                path: PathBuf::from("/project/tests/app.test.ts"),
364                source: EntryPointSource::TestFile,
365            },
366            EntryPoint {
367                path: PathBuf::from("/project/tests/setup.ts"),
368                source: EntryPointSource::Plugin {
369                    name: "vitest".to_string(),
370                },
371            },
372        ];
373        let runtime_entry_points = vec![EntryPoint {
374            path: PathBuf::from("/project/src/main.ts"),
375            source: EntryPointSource::PackageJsonMain,
376        }];
377        let test_entry_points = vec![EntryPoint {
378            path: PathBuf::from("/project/tests/app.test.ts"),
379            source: EntryPointSource::TestFile,
380        }];
381
382        let resolved_modules = vec![
383            ResolvedModule {
384                file_id: FileId(0),
385                path: PathBuf::from("/project/src/main.ts"),
386                resolved_imports: vec![ResolvedImport {
387                    info: ImportInfo {
388                        source: "./runtime-only".to_string(),
389                        imported_name: ImportedName::Named("runtimeOnly".to_string()),
390                        local_name: "runtimeOnly".to_string(),
391                        is_type_only: false,
392                        span: oxc_span::Span::new(0, 10),
393                        source_span: oxc_span::Span::default(),
394                    },
395                    target: ResolveResult::InternalModule(FileId(1)),
396                }],
397                ..Default::default()
398            },
399            ResolvedModule {
400                file_id: FileId(1),
401                path: PathBuf::from("/project/src/runtime-only.ts"),
402                exports: vec![fallow_types::extract::ExportInfo {
403                    name: ExportName::Named("runtimeOnly".to_string()),
404                    local_name: Some("runtimeOnly".to_string()),
405                    is_type_only: false,
406                    is_public: false,
407                    span: oxc_span::Span::new(0, 20),
408                    members: vec![],
409                    super_class: None,
410                }],
411                ..Default::default()
412            },
413            ResolvedModule {
414                file_id: FileId(2),
415                path: PathBuf::from("/project/tests/app.test.ts"),
416                resolved_imports: vec![ResolvedImport {
417                    info: ImportInfo {
418                        source: "../src/covered".to_string(),
419                        imported_name: ImportedName::Named("covered".to_string()),
420                        local_name: "covered".to_string(),
421                        is_type_only: false,
422                        span: oxc_span::Span::new(0, 10),
423                        source_span: oxc_span::Span::default(),
424                    },
425                    target: ResolveResult::InternalModule(FileId(4)),
426                }],
427                ..Default::default()
428            },
429            ResolvedModule {
430                file_id: FileId(3),
431                path: PathBuf::from("/project/tests/setup.ts"),
432                resolved_imports: vec![ResolvedImport {
433                    info: ImportInfo {
434                        source: "../src/runtime-only".to_string(),
435                        imported_name: ImportedName::Named("runtimeOnly".to_string()),
436                        local_name: "runtimeOnly".to_string(),
437                        is_type_only: false,
438                        span: oxc_span::Span::new(0, 10),
439                        source_span: oxc_span::Span::default(),
440                    },
441                    target: ResolveResult::InternalModule(FileId(1)),
442                }],
443                ..Default::default()
444            },
445            ResolvedModule {
446                file_id: FileId(4),
447                path: PathBuf::from("/project/src/covered.ts"),
448                exports: vec![fallow_types::extract::ExportInfo {
449                    name: ExportName::Named("covered".to_string()),
450                    local_name: Some("covered".to_string()),
451                    is_type_only: false,
452                    is_public: false,
453                    span: oxc_span::Span::new(0, 20),
454                    members: vec![],
455                    super_class: None,
456                }],
457                ..Default::default()
458            },
459        ];
460
461        let graph = ModuleGraph::build_with_reachability_roots(
462            &resolved_modules,
463            &all_entry_points,
464            &runtime_entry_points,
465            &test_entry_points,
466            &files,
467        );
468
469        assert!(graph.modules[1].is_reachable());
470        assert!(graph.modules[1].is_runtime_reachable());
471        assert!(
472            !graph.modules[1].is_test_reachable(),
473            "support roots should not make runtime-only modules test reachable"
474        );
475
476        assert!(graph.modules[4].is_reachable());
477        assert!(graph.modules[4].is_test_reachable());
478        assert!(
479            !graph.modules[4].is_runtime_reachable(),
480            "test-only reachability should stay separate from runtime roots"
481        );
482    }
483
484    #[test]
485    fn graph_export_has_reference() {
486        let graph = build_simple_graph();
487        let utils = &graph.modules[1];
488        let foo_export = utils
489            .exports
490            .iter()
491            .find(|e| e.name.to_string() == "foo")
492            .unwrap();
493        assert!(
494            !foo_export.references.is_empty(),
495            "foo should have references"
496        );
497    }
498
499    #[test]
500    fn graph_unused_export_no_reference() {
501        let graph = build_simple_graph();
502        let utils = &graph.modules[1];
503        let bar_export = utils
504            .exports
505            .iter()
506            .find(|e| e.name.to_string() == "bar")
507            .unwrap();
508        assert!(
509            bar_export.references.is_empty(),
510            "bar should have no references"
511        );
512    }
513
514    #[test]
515    fn graph_no_namespace_import() {
516        let graph = build_simple_graph();
517        assert!(!graph.has_namespace_import(FileId(0)));
518        assert!(!graph.has_namespace_import(FileId(1)));
519    }
520
521    #[test]
522    fn graph_has_namespace_import() {
523        let files = vec![
524            DiscoveredFile {
525                id: FileId(0),
526                path: PathBuf::from("/project/entry.ts"),
527                size_bytes: 100,
528            },
529            DiscoveredFile {
530                id: FileId(1),
531                path: PathBuf::from("/project/utils.ts"),
532                size_bytes: 50,
533            },
534        ];
535
536        let entry_points = vec![EntryPoint {
537            path: PathBuf::from("/project/entry.ts"),
538            source: EntryPointSource::PackageJsonMain,
539        }];
540
541        let resolved_modules = vec![
542            ResolvedModule {
543                file_id: FileId(0),
544                path: PathBuf::from("/project/entry.ts"),
545                resolved_imports: vec![ResolvedImport {
546                    info: ImportInfo {
547                        source: "./utils".to_string(),
548                        imported_name: ImportedName::Namespace,
549                        local_name: "utils".to_string(),
550                        is_type_only: false,
551                        span: oxc_span::Span::new(0, 10),
552                        source_span: oxc_span::Span::default(),
553                    },
554                    target: ResolveResult::InternalModule(FileId(1)),
555                }],
556                ..Default::default()
557            },
558            ResolvedModule {
559                file_id: FileId(1),
560                path: PathBuf::from("/project/utils.ts"),
561                exports: vec![fallow_types::extract::ExportInfo {
562                    name: ExportName::Named("foo".to_string()),
563                    local_name: Some("foo".to_string()),
564                    is_type_only: false,
565                    is_public: false,
566                    span: oxc_span::Span::new(0, 20),
567                    members: vec![],
568                    super_class: None,
569                }],
570                ..Default::default()
571            },
572        ];
573
574        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
575        assert!(
576            graph.has_namespace_import(FileId(1)),
577            "utils should have namespace import"
578        );
579    }
580
581    #[test]
582    fn graph_has_namespace_import_out_of_bounds() {
583        let graph = build_simple_graph();
584        assert!(!graph.has_namespace_import(FileId(999)));
585    }
586
587    #[test]
588    fn graph_unreachable_module() {
589        // Three files: entry imports utils, orphan is not imported
590        let files = vec![
591            DiscoveredFile {
592                id: FileId(0),
593                path: PathBuf::from("/project/entry.ts"),
594                size_bytes: 100,
595            },
596            DiscoveredFile {
597                id: FileId(1),
598                path: PathBuf::from("/project/utils.ts"),
599                size_bytes: 50,
600            },
601            DiscoveredFile {
602                id: FileId(2),
603                path: PathBuf::from("/project/orphan.ts"),
604                size_bytes: 30,
605            },
606        ];
607
608        let entry_points = vec![EntryPoint {
609            path: PathBuf::from("/project/entry.ts"),
610            source: EntryPointSource::PackageJsonMain,
611        }];
612
613        let resolved_modules = vec![
614            ResolvedModule {
615                file_id: FileId(0),
616                path: PathBuf::from("/project/entry.ts"),
617                resolved_imports: vec![ResolvedImport {
618                    info: ImportInfo {
619                        source: "./utils".to_string(),
620                        imported_name: ImportedName::Named("foo".to_string()),
621                        local_name: "foo".to_string(),
622                        is_type_only: false,
623                        span: oxc_span::Span::new(0, 10),
624                        source_span: oxc_span::Span::default(),
625                    },
626                    target: ResolveResult::InternalModule(FileId(1)),
627                }],
628                ..Default::default()
629            },
630            ResolvedModule {
631                file_id: FileId(1),
632                path: PathBuf::from("/project/utils.ts"),
633                exports: vec![fallow_types::extract::ExportInfo {
634                    name: ExportName::Named("foo".to_string()),
635                    local_name: Some("foo".to_string()),
636                    is_type_only: false,
637                    is_public: false,
638                    span: oxc_span::Span::new(0, 20),
639                    members: vec![],
640                    super_class: None,
641                }],
642                ..Default::default()
643            },
644            ResolvedModule {
645                file_id: FileId(2),
646                path: PathBuf::from("/project/orphan.ts"),
647                exports: vec![fallow_types::extract::ExportInfo {
648                    name: ExportName::Named("orphan".to_string()),
649                    local_name: Some("orphan".to_string()),
650                    is_type_only: false,
651                    is_public: false,
652                    span: oxc_span::Span::new(0, 20),
653                    members: vec![],
654                    super_class: None,
655                }],
656                ..Default::default()
657            },
658        ];
659
660        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
661
662        assert!(graph.modules[0].is_reachable(), "entry should be reachable");
663        assert!(graph.modules[1].is_reachable(), "utils should be reachable");
664        assert!(
665            !graph.modules[2].is_reachable(),
666            "orphan should NOT be reachable"
667        );
668    }
669
670    #[test]
671    fn graph_package_usage_tracked() {
672        let files = vec![DiscoveredFile {
673            id: FileId(0),
674            path: PathBuf::from("/project/entry.ts"),
675            size_bytes: 100,
676        }];
677
678        let entry_points = vec![EntryPoint {
679            path: PathBuf::from("/project/entry.ts"),
680            source: EntryPointSource::PackageJsonMain,
681        }];
682
683        let resolved_modules = vec![ResolvedModule {
684            file_id: FileId(0),
685            path: PathBuf::from("/project/entry.ts"),
686            exports: vec![],
687            re_exports: vec![],
688            resolved_imports: vec![
689                ResolvedImport {
690                    info: ImportInfo {
691                        source: "react".to_string(),
692                        imported_name: ImportedName::Default,
693                        local_name: "React".to_string(),
694                        is_type_only: false,
695                        span: oxc_span::Span::new(0, 10),
696                        source_span: oxc_span::Span::default(),
697                    },
698                    target: ResolveResult::NpmPackage("react".to_string()),
699                },
700                ResolvedImport {
701                    info: ImportInfo {
702                        source: "lodash".to_string(),
703                        imported_name: ImportedName::Named("merge".to_string()),
704                        local_name: "merge".to_string(),
705                        is_type_only: false,
706                        span: oxc_span::Span::new(15, 30),
707                        source_span: oxc_span::Span::default(),
708                    },
709                    target: ResolveResult::NpmPackage("lodash".to_string()),
710                },
711            ],
712            ..Default::default()
713        }];
714
715        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
716        assert!(graph.package_usage.contains_key("react"));
717        assert!(graph.package_usage.contains_key("lodash"));
718        assert!(!graph.package_usage.contains_key("express"));
719    }
720
721    #[test]
722    fn graph_empty() {
723        let graph = ModuleGraph::build(&[], &[], &[]);
724        assert_eq!(graph.module_count(), 0);
725        assert_eq!(graph.edge_count(), 0);
726    }
727
728    #[test]
729    fn graph_cjs_exports_tracked() {
730        let files = vec![DiscoveredFile {
731            id: FileId(0),
732            path: PathBuf::from("/project/entry.ts"),
733            size_bytes: 100,
734        }];
735
736        let entry_points = vec![EntryPoint {
737            path: PathBuf::from("/project/entry.ts"),
738            source: EntryPointSource::PackageJsonMain,
739        }];
740
741        let resolved_modules = vec![ResolvedModule {
742            file_id: FileId(0),
743            path: PathBuf::from("/project/entry.ts"),
744            has_cjs_exports: true,
745            ..Default::default()
746        }];
747
748        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
749        assert!(graph.modules[0].has_cjs_exports());
750    }
751
752    #[test]
753    fn graph_edges_for_returns_targets() {
754        let graph = build_simple_graph();
755        let targets = graph.edges_for(FileId(0));
756        assert_eq!(targets, vec![FileId(1)]);
757    }
758
759    #[test]
760    fn graph_edges_for_no_imports() {
761        let graph = build_simple_graph();
762        // utils.ts has no outgoing imports
763        let targets = graph.edges_for(FileId(1));
764        assert!(targets.is_empty());
765    }
766
767    #[test]
768    fn graph_edges_for_out_of_bounds() {
769        let graph = build_simple_graph();
770        let targets = graph.edges_for(FileId(999));
771        assert!(targets.is_empty());
772    }
773
774    #[test]
775    fn graph_find_import_span_start_found() {
776        let graph = build_simple_graph();
777        let span_start = graph.find_import_span_start(FileId(0), FileId(1));
778        assert!(span_start.is_some());
779        assert_eq!(span_start.unwrap(), 0);
780    }
781
782    #[test]
783    fn graph_find_import_span_start_wrong_target() {
784        let graph = build_simple_graph();
785        // No edge from entry.ts to itself
786        let span_start = graph.find_import_span_start(FileId(0), FileId(0));
787        assert!(span_start.is_none());
788    }
789
790    #[test]
791    fn graph_find_import_span_start_source_out_of_bounds() {
792        let graph = build_simple_graph();
793        let span_start = graph.find_import_span_start(FileId(999), FileId(1));
794        assert!(span_start.is_none());
795    }
796
797    #[test]
798    fn graph_find_import_span_start_no_edges() {
799        let graph = build_simple_graph();
800        // utils.ts has no outgoing edges
801        let span_start = graph.find_import_span_start(FileId(1), FileId(0));
802        assert!(span_start.is_none());
803    }
804
805    #[test]
806    fn graph_reverse_deps_populated() {
807        let graph = build_simple_graph();
808        // utils.ts (FileId(1)) should be imported by entry.ts (FileId(0))
809        assert!(graph.reverse_deps[1].contains(&FileId(0)));
810        // entry.ts (FileId(0)) should not be imported by anyone
811        assert!(graph.reverse_deps[0].is_empty());
812    }
813
814    #[test]
815    fn graph_type_only_package_usage_tracked() {
816        let files = vec![DiscoveredFile {
817            id: FileId(0),
818            path: PathBuf::from("/project/entry.ts"),
819            size_bytes: 100,
820        }];
821        let entry_points = vec![EntryPoint {
822            path: PathBuf::from("/project/entry.ts"),
823            source: EntryPointSource::PackageJsonMain,
824        }];
825        let resolved_modules = vec![ResolvedModule {
826            file_id: FileId(0),
827            path: PathBuf::from("/project/entry.ts"),
828            resolved_imports: vec![
829                ResolvedImport {
830                    info: ImportInfo {
831                        source: "react".to_string(),
832                        imported_name: ImportedName::Named("FC".to_string()),
833                        local_name: "FC".to_string(),
834                        is_type_only: true,
835                        span: oxc_span::Span::new(0, 10),
836                        source_span: oxc_span::Span::default(),
837                    },
838                    target: ResolveResult::NpmPackage("react".to_string()),
839                },
840                ResolvedImport {
841                    info: ImportInfo {
842                        source: "react".to_string(),
843                        imported_name: ImportedName::Named("useState".to_string()),
844                        local_name: "useState".to_string(),
845                        is_type_only: false,
846                        span: oxc_span::Span::new(15, 30),
847                        source_span: oxc_span::Span::default(),
848                    },
849                    target: ResolveResult::NpmPackage("react".to_string()),
850                },
851            ],
852            ..Default::default()
853        }];
854
855        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
856        assert!(graph.package_usage.contains_key("react"));
857        assert!(graph.type_only_package_usage.contains_key("react"));
858    }
859
860    #[test]
861    fn graph_default_import_reference() {
862        let files = vec![
863            DiscoveredFile {
864                id: FileId(0),
865                path: PathBuf::from("/project/entry.ts"),
866                size_bytes: 100,
867            },
868            DiscoveredFile {
869                id: FileId(1),
870                path: PathBuf::from("/project/utils.ts"),
871                size_bytes: 50,
872            },
873        ];
874        let entry_points = vec![EntryPoint {
875            path: PathBuf::from("/project/entry.ts"),
876            source: EntryPointSource::PackageJsonMain,
877        }];
878        let resolved_modules = vec![
879            ResolvedModule {
880                file_id: FileId(0),
881                path: PathBuf::from("/project/entry.ts"),
882                resolved_imports: vec![ResolvedImport {
883                    info: ImportInfo {
884                        source: "./utils".to_string(),
885                        imported_name: ImportedName::Default,
886                        local_name: "Utils".to_string(),
887                        is_type_only: false,
888                        span: oxc_span::Span::new(0, 10),
889                        source_span: oxc_span::Span::default(),
890                    },
891                    target: ResolveResult::InternalModule(FileId(1)),
892                }],
893                ..Default::default()
894            },
895            ResolvedModule {
896                file_id: FileId(1),
897                path: PathBuf::from("/project/utils.ts"),
898                exports: vec![fallow_types::extract::ExportInfo {
899                    name: ExportName::Default,
900                    local_name: None,
901                    is_type_only: false,
902                    is_public: false,
903                    span: oxc_span::Span::new(0, 20),
904                    members: vec![],
905                    super_class: None,
906                }],
907                ..Default::default()
908            },
909        ];
910
911        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
912        let utils = &graph.modules[1];
913        let default_export = utils
914            .exports
915            .iter()
916            .find(|e| matches!(e.name, ExportName::Default))
917            .unwrap();
918        assert!(!default_export.references.is_empty());
919        assert_eq!(
920            default_export.references[0].kind,
921            ReferenceKind::DefaultImport
922        );
923    }
924
925    #[test]
926    fn graph_side_effect_import_no_export_reference() {
927        let files = vec![
928            DiscoveredFile {
929                id: FileId(0),
930                path: PathBuf::from("/project/entry.ts"),
931                size_bytes: 100,
932            },
933            DiscoveredFile {
934                id: FileId(1),
935                path: PathBuf::from("/project/styles.ts"),
936                size_bytes: 50,
937            },
938        ];
939        let entry_points = vec![EntryPoint {
940            path: PathBuf::from("/project/entry.ts"),
941            source: EntryPointSource::PackageJsonMain,
942        }];
943        let resolved_modules = vec![
944            ResolvedModule {
945                file_id: FileId(0),
946                path: PathBuf::from("/project/entry.ts"),
947                resolved_imports: vec![ResolvedImport {
948                    info: ImportInfo {
949                        source: "./styles".to_string(),
950                        imported_name: ImportedName::SideEffect,
951                        local_name: String::new(),
952                        is_type_only: false,
953                        span: oxc_span::Span::new(0, 10),
954                        source_span: oxc_span::Span::default(),
955                    },
956                    target: ResolveResult::InternalModule(FileId(1)),
957                }],
958                ..Default::default()
959            },
960            ResolvedModule {
961                file_id: FileId(1),
962                path: PathBuf::from("/project/styles.ts"),
963                exports: vec![fallow_types::extract::ExportInfo {
964                    name: ExportName::Named("primaryColor".to_string()),
965                    local_name: Some("primaryColor".to_string()),
966                    is_type_only: false,
967                    is_public: false,
968                    span: oxc_span::Span::new(0, 20),
969                    members: vec![],
970                    super_class: None,
971                }],
972                ..Default::default()
973            },
974        ];
975
976        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
977        // Side-effect import should create an edge but not reference specific exports
978        assert_eq!(graph.edge_count(), 1);
979        let styles = &graph.modules[1];
980        let export = &styles.exports[0];
981        // Side-effect import doesn't match any named export
982        assert!(
983            export.references.is_empty(),
984            "side-effect import should not reference named exports"
985        );
986    }
987
988    #[test]
989    fn graph_multiple_entry_points() {
990        let files = vec![
991            DiscoveredFile {
992                id: FileId(0),
993                path: PathBuf::from("/project/main.ts"),
994                size_bytes: 100,
995            },
996            DiscoveredFile {
997                id: FileId(1),
998                path: PathBuf::from("/project/worker.ts"),
999                size_bytes: 100,
1000            },
1001            DiscoveredFile {
1002                id: FileId(2),
1003                path: PathBuf::from("/project/shared.ts"),
1004                size_bytes: 50,
1005            },
1006        ];
1007        let entry_points = vec![
1008            EntryPoint {
1009                path: PathBuf::from("/project/main.ts"),
1010                source: EntryPointSource::PackageJsonMain,
1011            },
1012            EntryPoint {
1013                path: PathBuf::from("/project/worker.ts"),
1014                source: EntryPointSource::PackageJsonMain,
1015            },
1016        ];
1017        let resolved_modules = vec![
1018            ResolvedModule {
1019                file_id: FileId(0),
1020                path: PathBuf::from("/project/main.ts"),
1021                resolved_imports: vec![ResolvedImport {
1022                    info: ImportInfo {
1023                        source: "./shared".to_string(),
1024                        imported_name: ImportedName::Named("helper".to_string()),
1025                        local_name: "helper".to_string(),
1026                        is_type_only: false,
1027                        span: oxc_span::Span::new(0, 10),
1028                        source_span: oxc_span::Span::default(),
1029                    },
1030                    target: ResolveResult::InternalModule(FileId(2)),
1031                }],
1032                ..Default::default()
1033            },
1034            ResolvedModule {
1035                file_id: FileId(1),
1036                path: PathBuf::from("/project/worker.ts"),
1037                ..Default::default()
1038            },
1039            ResolvedModule {
1040                file_id: FileId(2),
1041                path: PathBuf::from("/project/shared.ts"),
1042                exports: vec![fallow_types::extract::ExportInfo {
1043                    name: ExportName::Named("helper".to_string()),
1044                    local_name: Some("helper".to_string()),
1045                    is_type_only: false,
1046                    is_public: false,
1047                    span: oxc_span::Span::new(0, 20),
1048                    members: vec![],
1049                    super_class: None,
1050                }],
1051                ..Default::default()
1052            },
1053        ];
1054
1055        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1056        assert!(graph.modules[0].is_entry_point());
1057        assert!(graph.modules[1].is_entry_point());
1058        assert!(!graph.modules[2].is_entry_point());
1059        // All should be reachable — shared is reached from main
1060        assert!(graph.modules[0].is_reachable());
1061        assert!(graph.modules[1].is_reachable());
1062        assert!(graph.modules[2].is_reachable());
1063    }
1064}