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