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