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_export_reachability;
12mod re_exports;
13mod reachability;
14pub mod types;
15
16use std::path::Path;
17
18use fixedbitset::FixedBitSet;
19use rustc_hash::{FxHashMap, FxHashSet};
20
21use crate::resolve::ResolvedModule;
22use fallow_types::discover::{DiscoveredFile, EntryPoint, FileId};
23use fallow_types::extract::ImportedName;
24
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/// Importer details for one file that directly imports a target module.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct DirectImporterSummary {
106    /// Source file that imports the requested target.
107    pub source: FileId,
108    /// Symbols imported from the target by this source file.
109    pub symbols: Vec<ImportedSymbolSummary>,
110}
111
112/// Symbol details for a direct import edge.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct ImportedSymbolSummary {
115    /// Imported binding name, using `default`, `*`, and `side-effect` for
116    /// non-named imports.
117    pub imported: String,
118    /// Local binding name in the importing file.
119    pub local: String,
120    /// Whether this symbol came from a type-only import.
121    pub type_only: bool,
122}
123
124#[cfg(target_pointer_width = "64")]
125const _: () = assert!(std::mem::size_of::<Edge>() == 32);
126#[cfg(target_pointer_width = "64")]
127const _: () = assert!(std::mem::size_of::<ImportedSymbol>() == 64);
128
129impl ModuleGraph {
130    fn resolve_entry_point_ids(
131        entry_points: &[EntryPoint],
132        path_to_id: &FxHashMap<&Path, FileId>,
133    ) -> FxHashSet<FileId> {
134        entry_points
135            .iter()
136            .filter_map(|ep| {
137                path_to_id.get(ep.path.as_path()).copied().or_else(|| {
138                    dunce::canonicalize(&ep.path)
139                        .ok()
140                        .and_then(|path| path_to_id.get(path.as_path()).copied())
141                })
142            })
143            .collect()
144    }
145
146    /// Build the module graph from resolved modules and entry points.
147    pub fn build(
148        resolved_modules: &[ResolvedModule],
149        entry_points: &[EntryPoint],
150        files: &[DiscoveredFile],
151    ) -> Self {
152        Self::build_with_reachability_roots(
153            resolved_modules,
154            entry_points,
155            entry_points,
156            &[],
157            files,
158        )
159    }
160
161    /// Build the module graph with explicit runtime and test reachability roots.
162    pub fn build_with_reachability_roots(
163        resolved_modules: &[ResolvedModule],
164        entry_points: &[EntryPoint],
165        runtime_entry_points: &[EntryPoint],
166        test_entry_points: &[EntryPoint],
167        files: &[DiscoveredFile],
168    ) -> Self {
169        let _span = tracing::info_span!("build_graph").entered();
170
171        let module_count = files.len();
172
173        let max_file_id = files
174            .iter()
175            .map(|f| f.id.0 as usize)
176            .max()
177            .map_or(0, |m| m + 1);
178        let total_capacity = max_file_id.max(module_count);
179
180        let path_to_id: FxHashMap<&Path, FileId> =
181            files.iter().map(|f| (f.path.as_path(), f.id)).collect();
182
183        let module_by_id: FxHashMap<FileId, &ResolvedModule> =
184            resolved_modules.iter().map(|m| (m.file_id, m)).collect();
185
186        let mut entry_point_ids = Self::resolve_entry_point_ids(entry_points, &path_to_id);
187        let runtime_entry_point_ids =
188            Self::resolve_entry_point_ids(runtime_entry_points, &path_to_id);
189        let test_entry_point_ids = Self::resolve_entry_point_ids(test_entry_points, &path_to_id);
190
191        for file in files {
192            if is_declaration_file_path(&file.path) {
193                entry_point_ids.insert(file.id);
194            }
195        }
196
197        let mut graph = Self::populate_edges(&build::PopulateEdgesInput {
198            files,
199            module_by_id: &module_by_id,
200            entry_point_ids: &entry_point_ids,
201            runtime_entry_point_ids: &runtime_entry_point_ids,
202            test_entry_point_ids: &test_entry_point_ids,
203            module_count,
204            total_capacity,
205        });
206
207        graph.populate_references(&module_by_id, &entry_point_ids);
208
209        namespace_aliases::propagate_cross_package_aliases(&mut graph, &module_by_id);
210
211        namespace_re_exports::propagate_namespace_re_exports(&mut graph, &module_by_id);
212
213        graph.mark_reachable(
214            &entry_point_ids,
215            &runtime_entry_point_ids,
216            &test_entry_point_ids,
217            total_capacity,
218        );
219
220        graph.re_export_cycles = graph.resolve_re_export_chains();
221
222        graph
223    }
224
225    /// Total number of modules.
226    #[must_use]
227    pub const fn module_count(&self) -> usize {
228        self.modules.len()
229    }
230
231    /// Total number of edges.
232    #[must_use]
233    pub const fn edge_count(&self) -> usize {
234        self.edges.len()
235    }
236
237    /// Check if any importer uses `import * as ns` for this module.
238    /// Uses precomputed bitset, O(1) lookup.
239    #[must_use]
240    pub fn has_namespace_import(&self, file_id: FileId) -> bool {
241        let idx = file_id.0 as usize;
242        if idx >= self.namespace_imported.len() {
243            return false;
244        }
245        self.namespace_imported.contains(idx)
246    }
247
248    /// Get the target `FileId`s of all outgoing edges for a module.
249    #[must_use]
250    pub fn edges_for(&self, file_id: FileId) -> Vec<FileId> {
251        let idx = file_id.0 as usize;
252        if idx >= self.modules.len() {
253            return Vec::new();
254        }
255        let range = &self.modules[idx].edge_range;
256        self.edges[range.clone()].iter().map(|e| e.target).collect()
257    }
258
259    /// Summarize files that directly import `target`.
260    ///
261    /// Uses existing reverse dependency and edge indexes. Returns an empty
262    /// list when the target is out of range or has no importers.
263    #[must_use]
264    pub fn direct_importer_summaries(&self, target: FileId) -> Vec<DirectImporterSummary> {
265        let Some(importers) = self.reverse_deps.get(target.0 as usize) else {
266            return Vec::new();
267        };
268
269        let mut summaries = Vec::new();
270        for &source in importers {
271            let idx = source.0 as usize;
272            let Some(source_node) = self.modules.get(idx) else {
273                continue;
274            };
275            let mut symbols = Vec::new();
276            for edge in &self.edges[source_node.edge_range.clone()] {
277                if edge.target != target {
278                    continue;
279                }
280                symbols.extend(edge.symbols.iter().map(|symbol| ImportedSymbolSummary {
281                    imported: imported_name_label(&symbol.imported_name),
282                    local: symbol.local_name.clone(),
283                    type_only: symbol.is_type_only,
284                }));
285            }
286            symbols.sort_by(|a, b| {
287                a.imported
288                    .cmp(&b.imported)
289                    .then_with(|| a.local.cmp(&b.local))
290                    .then_with(|| a.type_only.cmp(&b.type_only))
291            });
292            symbols.dedup();
293            summaries.push(DirectImporterSummary { source, symbols });
294        }
295        summaries.sort_by_key(|summary| summary.source.0);
296        summaries
297    }
298
299    /// Find the byte offset of the import statement from `source` to `target`.
300    ///
301    /// Mixed type/value imports to the same target are stored as one edge. Prefer
302    /// the first value-carrying import so runtime-cycle diagnostics and line
303    /// suppressions anchor on the import that actually participates in the cycle.
304    /// Returns `None` if no edge exists or the edge has no symbols.
305    #[must_use]
306    pub fn find_import_span_start(&self, source: FileId, target: FileId) -> Option<u32> {
307        let idx = source.0 as usize;
308        if idx >= self.modules.len() {
309            return None;
310        }
311        let range = &self.modules[idx].edge_range;
312        for edge in &self.edges[range.clone()] {
313            if edge.target == target {
314                return edge
315                    .symbols
316                    .iter()
317                    .find(|s| !s.is_type_only)
318                    .or_else(|| edge.symbols.first())
319                    .map(|s| s.import_span.start);
320            }
321        }
322        None
323    }
324
325    /// Iterate outgoing edges with the data the boundary detector needs in a
326    /// single pass: target file id, whether every symbol on the edge is
327    /// type-only (matches the predicate used by cycle detection), and the
328    /// span start of the first value-carrying symbol (or the first symbol
329    /// when every symbol is type-only).
330    ///
331    /// When `featureB` has both `import type { Foo } from './x'` and
332    /// `import { bar } from './x'`, fallow groups them into ONE edge with the
333    /// type-only symbol first and the value symbol second. Consumers need the
334    /// value span so findings anchor on the runtime import line; otherwise a
335    /// `// fallow-ignore-next-line` above the type-only line would silently
336    /// suppress the real violation.
337    ///
338    /// Returns an empty iterator for out-of-range file ids.
339    pub fn outgoing_edge_summaries(
340        &self,
341        file_id: FileId,
342    ) -> impl Iterator<Item = (FileId, bool, Option<u32>)> + '_ {
343        let idx = file_id.0 as usize;
344        let range = if idx < self.modules.len() {
345            self.modules[idx].edge_range.clone()
346        } else {
347            0..0
348        };
349        self.edges[range].iter().map(|edge| {
350            let all_type_only =
351                !edge.symbols.is_empty() && edge.symbols.iter().all(|s| s.is_type_only);
352            let span = edge
353                .symbols
354                .iter()
355                .find(|s| !s.is_type_only)
356                .or_else(|| edge.symbols.first())
357                .map(|s| s.import_span.start);
358            (edge.target, all_type_only, span)
359        })
360    }
361
362    /// Like [`Self::outgoing_edge_summaries`] but additionally reports, as a
363    /// fourth boolean, whether EVERY non-type-only symbol on the edge has an
364    /// `import_span` start in `excluded_span_starts` (`all_client_only`). The
365    /// security `client-server-leak` BFS passes the `next/dynamic ssr:false`
366    /// dynamic-import span starts so it can skip an edge reached ONLY through the
367    /// client-only escape hatch. An edge with no non-type-only symbols, or with at
368    /// least one non-type-only symbol whose span is not excluded, reports `false`
369    /// (so a target also reached via a real static import stays in the cone).
370    ///
371    /// Returns an empty iterator for out-of-range file ids.
372    pub fn outgoing_edge_summaries_with_exclusions<'a>(
373        &'a self,
374        file_id: FileId,
375        excluded_span_starts: &'a FxHashSet<u32>,
376    ) -> impl Iterator<Item = (FileId, bool, Option<u32>, bool)> + 'a {
377        let idx = file_id.0 as usize;
378        let range = if idx < self.modules.len() {
379            self.modules[idx].edge_range.clone()
380        } else {
381            0..0
382        };
383        self.edges[range].iter().map(move |edge| {
384            let all_type_only =
385                !edge.symbols.is_empty() && edge.symbols.iter().all(|s| s.is_type_only);
386            let span = edge
387                .symbols
388                .iter()
389                .find(|s| !s.is_type_only)
390                .or_else(|| edge.symbols.first())
391                .map(|s| s.import_span.start);
392            // `all_client_only`: there is at least one non-type-only symbol and
393            // every such symbol's import span is in the excluded set. A
394            // non-excluded value symbol keeps the edge live.
395            let mut value_symbols = edge.symbols.iter().filter(|s| !s.is_type_only).peekable();
396            let all_client_only = value_symbols.peek().is_some()
397                && value_symbols.all(|s| excluded_span_starts.contains(&s.import_span.start));
398            (edge.target, all_type_only, span, all_client_only)
399        })
400    }
401}
402
403fn imported_name_label(name: &ImportedName) -> String {
404    match name {
405        ImportedName::Named(name) => name.clone(),
406        ImportedName::Default => "default".to_string(),
407        ImportedName::Namespace => "*".to_string(),
408        ImportedName::SideEffect => "side-effect".to_string(),
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
416    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
417    use fallow_types::extract::{ExportName, ImportInfo, ImportedName, VisibilityTag};
418    use std::path::PathBuf;
419
420    fn build_simple_graph() -> ModuleGraph {
421        let files = vec![
422            DiscoveredFile {
423                id: FileId(0),
424                path: PathBuf::from("/project/src/entry.ts"),
425                size_bytes: 100,
426            },
427            DiscoveredFile {
428                id: FileId(1),
429                path: PathBuf::from("/project/src/utils.ts"),
430                size_bytes: 50,
431            },
432        ];
433
434        let entry_points = vec![EntryPoint {
435            path: PathBuf::from("/project/src/entry.ts"),
436            source: EntryPointSource::PackageJsonMain,
437        }];
438
439        let resolved_modules = vec![
440            ResolvedModule {
441                file_id: FileId(0),
442                path: PathBuf::from("/project/src/entry.ts"),
443                resolved_imports: vec![ResolvedImport {
444                    info: ImportInfo {
445                        source: "./utils".to_string(),
446                        imported_name: ImportedName::Named("foo".to_string()),
447                        local_name: "foo".to_string(),
448                        is_type_only: false,
449                        from_style: false,
450                        span: oxc_span::Span::new(0, 10),
451                        source_span: oxc_span::Span::default(),
452                    },
453                    target: ResolveResult::InternalModule(FileId(1)),
454                }],
455                ..Default::default()
456            },
457            ResolvedModule {
458                file_id: FileId(1),
459                path: PathBuf::from("/project/src/utils.ts"),
460                exports: vec![
461                    fallow_types::extract::ExportInfo {
462                        name: ExportName::Named("foo".to_string()),
463                        local_name: Some("foo".to_string()),
464                        is_type_only: false,
465                        visibility: VisibilityTag::None,
466                        expected_unused_reason: None,
467                        span: oxc_span::Span::new(0, 20),
468                        members: vec![],
469                        is_side_effect_used: false,
470                        super_class: None,
471                    },
472                    fallow_types::extract::ExportInfo {
473                        name: ExportName::Named("bar".to_string()),
474                        local_name: Some("bar".to_string()),
475                        is_type_only: false,
476                        visibility: VisibilityTag::None,
477                        expected_unused_reason: None,
478                        span: oxc_span::Span::new(25, 45),
479                        members: vec![],
480                        is_side_effect_used: false,
481                        super_class: None,
482                    },
483                ],
484                ..Default::default()
485            },
486        ];
487
488        ModuleGraph::build(&resolved_modules, &entry_points, &files)
489    }
490
491    #[test]
492    fn graph_module_count() {
493        let graph = build_simple_graph();
494        assert_eq!(graph.module_count(), 2);
495    }
496
497    #[test]
498    fn graph_edge_count() {
499        let graph = build_simple_graph();
500        assert_eq!(graph.edge_count(), 1);
501    }
502
503    #[test]
504    fn graph_entry_point_is_reachable() {
505        let graph = build_simple_graph();
506        assert!(graph.modules[0].is_entry_point());
507        assert!(graph.modules[0].is_reachable());
508    }
509
510    #[test]
511    fn graph_imported_module_is_reachable() {
512        let graph = build_simple_graph();
513        assert!(!graph.modules[1].is_entry_point());
514        assert!(graph.modules[1].is_reachable());
515    }
516
517    #[test]
518    #[expect(
519        clippy::too_many_lines,
520        reason = "this test fixture exercises four reachability roles end-to-end; splitting it \
521                  would obscure the cross-role assertions"
522    )]
523    fn graph_distinguishes_runtime_test_and_support_reachability() {
524        let files = vec![
525            DiscoveredFile {
526                id: FileId(0),
527                path: PathBuf::from("/project/src/main.ts"),
528                size_bytes: 100,
529            },
530            DiscoveredFile {
531                id: FileId(1),
532                path: PathBuf::from("/project/src/runtime-only.ts"),
533                size_bytes: 50,
534            },
535            DiscoveredFile {
536                id: FileId(2),
537                path: PathBuf::from("/project/tests/app.test.ts"),
538                size_bytes: 50,
539            },
540            DiscoveredFile {
541                id: FileId(3),
542                path: PathBuf::from("/project/tests/setup.ts"),
543                size_bytes: 50,
544            },
545            DiscoveredFile {
546                id: FileId(4),
547                path: PathBuf::from("/project/src/covered.ts"),
548                size_bytes: 50,
549            },
550        ];
551
552        let all_entry_points = vec![
553            EntryPoint {
554                path: PathBuf::from("/project/src/main.ts"),
555                source: EntryPointSource::PackageJsonMain,
556            },
557            EntryPoint {
558                path: PathBuf::from("/project/tests/app.test.ts"),
559                source: EntryPointSource::TestFile,
560            },
561            EntryPoint {
562                path: PathBuf::from("/project/tests/setup.ts"),
563                source: EntryPointSource::Plugin {
564                    name: "vitest".to_string(),
565                },
566            },
567        ];
568        let runtime_entry_points = vec![EntryPoint {
569            path: PathBuf::from("/project/src/main.ts"),
570            source: EntryPointSource::PackageJsonMain,
571        }];
572        let test_entry_points = vec![EntryPoint {
573            path: PathBuf::from("/project/tests/app.test.ts"),
574            source: EntryPointSource::TestFile,
575        }];
576
577        let resolved_modules = vec![
578            ResolvedModule {
579                file_id: FileId(0),
580                path: PathBuf::from("/project/src/main.ts"),
581                resolved_imports: vec![ResolvedImport {
582                    info: ImportInfo {
583                        source: "./runtime-only".to_string(),
584                        imported_name: ImportedName::Named("runtimeOnly".to_string()),
585                        local_name: "runtimeOnly".to_string(),
586                        is_type_only: false,
587                        from_style: false,
588                        span: oxc_span::Span::new(0, 10),
589                        source_span: oxc_span::Span::default(),
590                    },
591                    target: ResolveResult::InternalModule(FileId(1)),
592                }],
593                ..Default::default()
594            },
595            ResolvedModule {
596                file_id: FileId(1),
597                path: PathBuf::from("/project/src/runtime-only.ts"),
598                exports: vec![fallow_types::extract::ExportInfo {
599                    name: ExportName::Named("runtimeOnly".to_string()),
600                    local_name: Some("runtimeOnly".to_string()),
601                    is_type_only: false,
602                    visibility: VisibilityTag::None,
603                    expected_unused_reason: None,
604                    span: oxc_span::Span::new(0, 20),
605                    members: vec![],
606                    is_side_effect_used: false,
607                    super_class: None,
608                }],
609                ..Default::default()
610            },
611            ResolvedModule {
612                file_id: FileId(2),
613                path: PathBuf::from("/project/tests/app.test.ts"),
614                resolved_imports: vec![ResolvedImport {
615                    info: ImportInfo {
616                        source: "../src/covered".to_string(),
617                        imported_name: ImportedName::Named("covered".to_string()),
618                        local_name: "covered".to_string(),
619                        is_type_only: false,
620                        from_style: false,
621                        span: oxc_span::Span::new(0, 10),
622                        source_span: oxc_span::Span::default(),
623                    },
624                    target: ResolveResult::InternalModule(FileId(4)),
625                }],
626                ..Default::default()
627            },
628            ResolvedModule {
629                file_id: FileId(3),
630                path: PathBuf::from("/project/tests/setup.ts"),
631                resolved_imports: vec![ResolvedImport {
632                    info: ImportInfo {
633                        source: "../src/runtime-only".to_string(),
634                        imported_name: ImportedName::Named("runtimeOnly".to_string()),
635                        local_name: "runtimeOnly".to_string(),
636                        is_type_only: false,
637                        from_style: false,
638                        span: oxc_span::Span::new(0, 10),
639                        source_span: oxc_span::Span::default(),
640                    },
641                    target: ResolveResult::InternalModule(FileId(1)),
642                }],
643                ..Default::default()
644            },
645            ResolvedModule {
646                file_id: FileId(4),
647                path: PathBuf::from("/project/src/covered.ts"),
648                exports: vec![fallow_types::extract::ExportInfo {
649                    name: ExportName::Named("covered".to_string()),
650                    local_name: Some("covered".to_string()),
651                    is_type_only: false,
652                    visibility: VisibilityTag::None,
653                    expected_unused_reason: None,
654                    span: oxc_span::Span::new(0, 20),
655                    members: vec![],
656                    is_side_effect_used: false,
657                    super_class: None,
658                }],
659                ..Default::default()
660            },
661        ];
662
663        let graph = ModuleGraph::build_with_reachability_roots(
664            &resolved_modules,
665            &all_entry_points,
666            &runtime_entry_points,
667            &test_entry_points,
668            &files,
669        );
670
671        assert!(graph.modules[1].is_reachable());
672        assert!(graph.modules[1].is_runtime_reachable());
673        assert!(
674            !graph.modules[1].is_test_reachable(),
675            "support roots should not make runtime-only modules test reachable"
676        );
677
678        assert!(graph.modules[4].is_reachable());
679        assert!(graph.modules[4].is_test_reachable());
680        assert!(
681            !graph.modules[4].is_runtime_reachable(),
682            "test-only reachability should stay separate from runtime roots"
683        );
684    }
685
686    #[test]
687    fn graph_export_has_reference() {
688        let graph = build_simple_graph();
689        let utils = &graph.modules[1];
690        let foo_export = utils
691            .exports
692            .iter()
693            .find(|e| e.name.to_string() == "foo")
694            .unwrap();
695        assert!(
696            !foo_export.references.is_empty(),
697            "foo should have references"
698        );
699    }
700
701    #[test]
702    fn graph_unused_export_no_reference() {
703        let graph = build_simple_graph();
704        let utils = &graph.modules[1];
705        let bar_export = utils
706            .exports
707            .iter()
708            .find(|e| e.name.to_string() == "bar")
709            .unwrap();
710        assert!(
711            bar_export.references.is_empty(),
712            "bar should have no references"
713        );
714    }
715
716    #[test]
717    fn graph_no_namespace_import() {
718        let graph = build_simple_graph();
719        assert!(!graph.has_namespace_import(FileId(0)));
720        assert!(!graph.has_namespace_import(FileId(1)));
721    }
722
723    #[test]
724    fn graph_has_namespace_import() {
725        let files = vec![
726            DiscoveredFile {
727                id: FileId(0),
728                path: PathBuf::from("/project/entry.ts"),
729                size_bytes: 100,
730            },
731            DiscoveredFile {
732                id: FileId(1),
733                path: PathBuf::from("/project/utils.ts"),
734                size_bytes: 50,
735            },
736        ];
737
738        let entry_points = vec![EntryPoint {
739            path: PathBuf::from("/project/entry.ts"),
740            source: EntryPointSource::PackageJsonMain,
741        }];
742
743        let resolved_modules = vec![
744            ResolvedModule {
745                file_id: FileId(0),
746                path: PathBuf::from("/project/entry.ts"),
747                resolved_imports: vec![ResolvedImport {
748                    info: ImportInfo {
749                        source: "./utils".to_string(),
750                        imported_name: ImportedName::Namespace,
751                        local_name: "utils".to_string(),
752                        is_type_only: false,
753                        from_style: false,
754                        span: oxc_span::Span::new(0, 10),
755                        source_span: oxc_span::Span::default(),
756                    },
757                    target: ResolveResult::InternalModule(FileId(1)),
758                }],
759                ..Default::default()
760            },
761            ResolvedModule {
762                file_id: FileId(1),
763                path: PathBuf::from("/project/utils.ts"),
764                exports: vec![fallow_types::extract::ExportInfo {
765                    name: ExportName::Named("foo".to_string()),
766                    local_name: Some("foo".to_string()),
767                    is_type_only: false,
768                    visibility: VisibilityTag::None,
769                    expected_unused_reason: None,
770                    span: oxc_span::Span::new(0, 20),
771                    members: vec![],
772                    is_side_effect_used: false,
773                    super_class: None,
774                }],
775                ..Default::default()
776            },
777        ];
778
779        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
780        assert!(
781            graph.has_namespace_import(FileId(1)),
782            "utils should have namespace import"
783        );
784    }
785
786    #[test]
787    fn graph_has_namespace_import_out_of_bounds() {
788        let graph = build_simple_graph();
789        assert!(!graph.has_namespace_import(FileId(999)));
790    }
791
792    #[test]
793    fn graph_unreachable_module() {
794        let files = vec![
795            DiscoveredFile {
796                id: FileId(0),
797                path: PathBuf::from("/project/entry.ts"),
798                size_bytes: 100,
799            },
800            DiscoveredFile {
801                id: FileId(1),
802                path: PathBuf::from("/project/utils.ts"),
803                size_bytes: 50,
804            },
805            DiscoveredFile {
806                id: FileId(2),
807                path: PathBuf::from("/project/orphan.ts"),
808                size_bytes: 30,
809            },
810        ];
811
812        let entry_points = vec![EntryPoint {
813            path: PathBuf::from("/project/entry.ts"),
814            source: EntryPointSource::PackageJsonMain,
815        }];
816
817        let resolved_modules = vec![
818            ResolvedModule {
819                file_id: FileId(0),
820                path: PathBuf::from("/project/entry.ts"),
821                resolved_imports: vec![ResolvedImport {
822                    info: ImportInfo {
823                        source: "./utils".to_string(),
824                        imported_name: ImportedName::Named("foo".to_string()),
825                        local_name: "foo".to_string(),
826                        is_type_only: false,
827                        from_style: false,
828                        span: oxc_span::Span::new(0, 10),
829                        source_span: oxc_span::Span::default(),
830                    },
831                    target: ResolveResult::InternalModule(FileId(1)),
832                }],
833                ..Default::default()
834            },
835            ResolvedModule {
836                file_id: FileId(1),
837                path: PathBuf::from("/project/utils.ts"),
838                exports: vec![fallow_types::extract::ExportInfo {
839                    name: ExportName::Named("foo".to_string()),
840                    local_name: Some("foo".to_string()),
841                    is_type_only: false,
842                    visibility: VisibilityTag::None,
843                    expected_unused_reason: None,
844                    span: oxc_span::Span::new(0, 20),
845                    members: vec![],
846                    is_side_effect_used: false,
847                    super_class: None,
848                }],
849                ..Default::default()
850            },
851            ResolvedModule {
852                file_id: FileId(2),
853                path: PathBuf::from("/project/orphan.ts"),
854                exports: vec![fallow_types::extract::ExportInfo {
855                    name: ExportName::Named("orphan".to_string()),
856                    local_name: Some("orphan".to_string()),
857                    is_type_only: false,
858                    visibility: VisibilityTag::None,
859                    expected_unused_reason: None,
860                    span: oxc_span::Span::new(0, 20),
861                    members: vec![],
862                    is_side_effect_used: false,
863                    super_class: None,
864                }],
865                ..Default::default()
866            },
867        ];
868
869        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
870
871        assert!(graph.modules[0].is_reachable(), "entry should be reachable");
872        assert!(graph.modules[1].is_reachable(), "utils should be reachable");
873        assert!(
874            !graph.modules[2].is_reachable(),
875            "orphan should NOT be reachable"
876        );
877    }
878
879    #[test]
880    fn graph_package_usage_tracked() {
881        let files = vec![DiscoveredFile {
882            id: FileId(0),
883            path: PathBuf::from("/project/entry.ts"),
884            size_bytes: 100,
885        }];
886
887        let entry_points = vec![EntryPoint {
888            path: PathBuf::from("/project/entry.ts"),
889            source: EntryPointSource::PackageJsonMain,
890        }];
891
892        let resolved_modules = vec![ResolvedModule {
893            file_id: FileId(0),
894            path: PathBuf::from("/project/entry.ts"),
895            exports: vec![],
896            re_exports: vec![],
897            resolved_imports: vec![
898                ResolvedImport {
899                    info: ImportInfo {
900                        source: "react".to_string(),
901                        imported_name: ImportedName::Default,
902                        local_name: "React".to_string(),
903                        is_type_only: false,
904                        from_style: false,
905                        span: oxc_span::Span::new(0, 10),
906                        source_span: oxc_span::Span::default(),
907                    },
908                    target: ResolveResult::NpmPackage("react".to_string()),
909                },
910                ResolvedImport {
911                    info: ImportInfo {
912                        source: "lodash".to_string(),
913                        imported_name: ImportedName::Named("merge".to_string()),
914                        local_name: "merge".to_string(),
915                        is_type_only: false,
916                        from_style: false,
917                        span: oxc_span::Span::new(15, 30),
918                        source_span: oxc_span::Span::default(),
919                    },
920                    target: ResolveResult::NpmPackage("lodash".to_string()),
921                },
922            ],
923            ..Default::default()
924        }];
925
926        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
927        assert!(graph.package_usage.contains_key("react"));
928        assert!(graph.package_usage.contains_key("lodash"));
929        assert!(!graph.package_usage.contains_key("express"));
930    }
931
932    #[test]
933    fn graph_empty() {
934        let graph = ModuleGraph::build(&[], &[], &[]);
935        assert_eq!(graph.module_count(), 0);
936        assert_eq!(graph.edge_count(), 0);
937    }
938
939    #[test]
940    fn graph_cjs_exports_tracked() {
941        let files = vec![DiscoveredFile {
942            id: FileId(0),
943            path: PathBuf::from("/project/entry.ts"),
944            size_bytes: 100,
945        }];
946
947        let entry_points = vec![EntryPoint {
948            path: PathBuf::from("/project/entry.ts"),
949            source: EntryPointSource::PackageJsonMain,
950        }];
951
952        let resolved_modules = vec![ResolvedModule {
953            file_id: FileId(0),
954            path: PathBuf::from("/project/entry.ts"),
955            has_cjs_exports: true,
956            has_angular_component_template_url: false,
957            ..Default::default()
958        }];
959
960        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
961        assert!(graph.modules[0].has_cjs_exports());
962    }
963
964    #[test]
965    fn graph_edges_for_returns_targets() {
966        let graph = build_simple_graph();
967        let targets = graph.edges_for(FileId(0));
968        assert_eq!(targets, vec![FileId(1)]);
969    }
970
971    #[test]
972    fn graph_edges_for_no_imports() {
973        let graph = build_simple_graph();
974        let targets = graph.edges_for(FileId(1));
975        assert!(targets.is_empty());
976    }
977
978    #[test]
979    fn graph_edges_for_out_of_bounds() {
980        let graph = build_simple_graph();
981        let targets = graph.edges_for(FileId(999));
982        assert!(targets.is_empty());
983    }
984
985    #[test]
986    fn graph_direct_importer_summaries_include_symbols() {
987        let graph = build_simple_graph();
988        let summaries = graph.direct_importer_summaries(FileId(1));
989
990        assert_eq!(
991            summaries,
992            vec![DirectImporterSummary {
993                source: FileId(0),
994                symbols: vec![ImportedSymbolSummary {
995                    imported: "foo".to_string(),
996                    local: "foo".to_string(),
997                    type_only: false,
998                }],
999            }]
1000        );
1001    }
1002
1003    #[test]
1004    fn graph_find_import_span_start_found() {
1005        let graph = build_simple_graph();
1006        let span_start = graph.find_import_span_start(FileId(0), FileId(1));
1007        assert!(span_start.is_some());
1008        assert_eq!(span_start.unwrap(), 0);
1009    }
1010
1011    #[test]
1012    fn graph_find_import_span_start_prefers_value_import_on_mixed_edge() {
1013        let files = vec![
1014            DiscoveredFile {
1015                id: FileId(0),
1016                path: PathBuf::from("/project/entry.ts"),
1017                size_bytes: 100,
1018            },
1019            DiscoveredFile {
1020                id: FileId(1),
1021                path: PathBuf::from("/project/utils.ts"),
1022                size_bytes: 50,
1023            },
1024        ];
1025        let entry_points = vec![EntryPoint {
1026            path: PathBuf::from("/project/entry.ts"),
1027            source: EntryPointSource::PackageJsonMain,
1028        }];
1029        let resolved_modules = vec![
1030            ResolvedModule {
1031                file_id: FileId(0),
1032                path: PathBuf::from("/project/entry.ts"),
1033                resolved_imports: vec![
1034                    ResolvedImport {
1035                        info: ImportInfo {
1036                            source: "./utils".to_string(),
1037                            imported_name: ImportedName::Named("Foo".to_string()),
1038                            local_name: "Foo".to_string(),
1039                            is_type_only: true,
1040                            from_style: false,
1041                            span: oxc_span::Span::new(10, 20),
1042                            source_span: oxc_span::Span::default(),
1043                        },
1044                        target: ResolveResult::InternalModule(FileId(1)),
1045                    },
1046                    ResolvedImport {
1047                        info: ImportInfo {
1048                            source: "./utils".to_string(),
1049                            imported_name: ImportedName::Named("foo".to_string()),
1050                            local_name: "foo".to_string(),
1051                            is_type_only: false,
1052                            from_style: false,
1053                            span: oxc_span::Span::new(50, 60),
1054                            source_span: oxc_span::Span::default(),
1055                        },
1056                        target: ResolveResult::InternalModule(FileId(1)),
1057                    },
1058                ],
1059                ..Default::default()
1060            },
1061            ResolvedModule {
1062                file_id: FileId(1),
1063                path: PathBuf::from("/project/utils.ts"),
1064                ..Default::default()
1065            },
1066        ];
1067
1068        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1069        assert_eq!(graph.find_import_span_start(FileId(0), FileId(1)), Some(50));
1070    }
1071
1072    #[test]
1073    fn graph_find_import_span_start_wrong_target() {
1074        let graph = build_simple_graph();
1075        let span_start = graph.find_import_span_start(FileId(0), FileId(0));
1076        assert!(span_start.is_none());
1077    }
1078
1079    #[test]
1080    fn graph_find_import_span_start_source_out_of_bounds() {
1081        let graph = build_simple_graph();
1082        let span_start = graph.find_import_span_start(FileId(999), FileId(1));
1083        assert!(span_start.is_none());
1084    }
1085
1086    #[test]
1087    fn graph_find_import_span_start_no_edges() {
1088        let graph = build_simple_graph();
1089        let span_start = graph.find_import_span_start(FileId(1), FileId(0));
1090        assert!(span_start.is_none());
1091    }
1092
1093    #[test]
1094    fn graph_reverse_deps_populated() {
1095        let graph = build_simple_graph();
1096        assert!(graph.reverse_deps[1].contains(&FileId(0)));
1097        assert!(graph.reverse_deps[0].is_empty());
1098    }
1099
1100    #[test]
1101    fn graph_type_only_package_usage_tracked() {
1102        let files = vec![DiscoveredFile {
1103            id: FileId(0),
1104            path: PathBuf::from("/project/entry.ts"),
1105            size_bytes: 100,
1106        }];
1107        let entry_points = vec![EntryPoint {
1108            path: PathBuf::from("/project/entry.ts"),
1109            source: EntryPointSource::PackageJsonMain,
1110        }];
1111        let resolved_modules = vec![ResolvedModule {
1112            file_id: FileId(0),
1113            path: PathBuf::from("/project/entry.ts"),
1114            resolved_imports: vec![
1115                ResolvedImport {
1116                    info: ImportInfo {
1117                        source: "react".to_string(),
1118                        imported_name: ImportedName::Named("FC".to_string()),
1119                        local_name: "FC".to_string(),
1120                        is_type_only: true,
1121                        from_style: false,
1122                        span: oxc_span::Span::new(0, 10),
1123                        source_span: oxc_span::Span::default(),
1124                    },
1125                    target: ResolveResult::NpmPackage("react".to_string()),
1126                },
1127                ResolvedImport {
1128                    info: ImportInfo {
1129                        source: "react".to_string(),
1130                        imported_name: ImportedName::Named("useState".to_string()),
1131                        local_name: "useState".to_string(),
1132                        is_type_only: false,
1133                        from_style: false,
1134                        span: oxc_span::Span::new(15, 30),
1135                        source_span: oxc_span::Span::default(),
1136                    },
1137                    target: ResolveResult::NpmPackage("react".to_string()),
1138                },
1139            ],
1140            ..Default::default()
1141        }];
1142
1143        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1144        assert!(graph.package_usage.contains_key("react"));
1145        assert!(graph.type_only_package_usage.contains_key("react"));
1146    }
1147
1148    #[test]
1149    fn graph_default_import_reference() {
1150        let files = vec![
1151            DiscoveredFile {
1152                id: FileId(0),
1153                path: PathBuf::from("/project/entry.ts"),
1154                size_bytes: 100,
1155            },
1156            DiscoveredFile {
1157                id: FileId(1),
1158                path: PathBuf::from("/project/utils.ts"),
1159                size_bytes: 50,
1160            },
1161        ];
1162        let entry_points = vec![EntryPoint {
1163            path: PathBuf::from("/project/entry.ts"),
1164            source: EntryPointSource::PackageJsonMain,
1165        }];
1166        let resolved_modules = vec![
1167            ResolvedModule {
1168                file_id: FileId(0),
1169                path: PathBuf::from("/project/entry.ts"),
1170                resolved_imports: vec![ResolvedImport {
1171                    info: ImportInfo {
1172                        source: "./utils".to_string(),
1173                        imported_name: ImportedName::Default,
1174                        local_name: "Utils".to_string(),
1175                        is_type_only: false,
1176                        from_style: false,
1177                        span: oxc_span::Span::new(0, 10),
1178                        source_span: oxc_span::Span::default(),
1179                    },
1180                    target: ResolveResult::InternalModule(FileId(1)),
1181                }],
1182                ..Default::default()
1183            },
1184            ResolvedModule {
1185                file_id: FileId(1),
1186                path: PathBuf::from("/project/utils.ts"),
1187                exports: vec![fallow_types::extract::ExportInfo {
1188                    name: ExportName::Default,
1189                    local_name: None,
1190                    is_type_only: false,
1191                    visibility: VisibilityTag::None,
1192                    expected_unused_reason: None,
1193                    span: oxc_span::Span::new(0, 20),
1194                    members: vec![],
1195                    is_side_effect_used: false,
1196                    super_class: None,
1197                }],
1198                ..Default::default()
1199            },
1200        ];
1201
1202        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1203        let utils = &graph.modules[1];
1204        let default_export = utils
1205            .exports
1206            .iter()
1207            .find(|e| matches!(e.name, ExportName::Default))
1208            .unwrap();
1209        assert!(!default_export.references.is_empty());
1210        assert_eq!(
1211            default_export.references[0].kind,
1212            ReferenceKind::DefaultImport
1213        );
1214    }
1215
1216    #[test]
1217    fn graph_side_effect_import_no_export_reference() {
1218        let files = vec![
1219            DiscoveredFile {
1220                id: FileId(0),
1221                path: PathBuf::from("/project/entry.ts"),
1222                size_bytes: 100,
1223            },
1224            DiscoveredFile {
1225                id: FileId(1),
1226                path: PathBuf::from("/project/styles.ts"),
1227                size_bytes: 50,
1228            },
1229        ];
1230        let entry_points = vec![EntryPoint {
1231            path: PathBuf::from("/project/entry.ts"),
1232            source: EntryPointSource::PackageJsonMain,
1233        }];
1234        let resolved_modules = vec![
1235            ResolvedModule {
1236                file_id: FileId(0),
1237                path: PathBuf::from("/project/entry.ts"),
1238                resolved_imports: vec![ResolvedImport {
1239                    info: ImportInfo {
1240                        source: "./styles".to_string(),
1241                        imported_name: ImportedName::SideEffect,
1242                        local_name: String::new(),
1243                        is_type_only: false,
1244                        from_style: false,
1245                        span: oxc_span::Span::new(0, 10),
1246                        source_span: oxc_span::Span::default(),
1247                    },
1248                    target: ResolveResult::InternalModule(FileId(1)),
1249                }],
1250                ..Default::default()
1251            },
1252            ResolvedModule {
1253                file_id: FileId(1),
1254                path: PathBuf::from("/project/styles.ts"),
1255                exports: vec![fallow_types::extract::ExportInfo {
1256                    name: ExportName::Named("primaryColor".to_string()),
1257                    local_name: Some("primaryColor".to_string()),
1258                    is_type_only: false,
1259                    visibility: VisibilityTag::None,
1260                    expected_unused_reason: None,
1261                    span: oxc_span::Span::new(0, 20),
1262                    members: vec![],
1263                    is_side_effect_used: false,
1264                    super_class: None,
1265                }],
1266                ..Default::default()
1267            },
1268        ];
1269
1270        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1271        assert_eq!(graph.edge_count(), 1);
1272        let styles = &graph.modules[1];
1273        let export = &styles.exports[0];
1274        assert!(
1275            export.references.is_empty(),
1276            "side-effect import should not reference named exports"
1277        );
1278    }
1279
1280    #[test]
1281    fn graph_multiple_entry_points() {
1282        let files = vec![
1283            DiscoveredFile {
1284                id: FileId(0),
1285                path: PathBuf::from("/project/main.ts"),
1286                size_bytes: 100,
1287            },
1288            DiscoveredFile {
1289                id: FileId(1),
1290                path: PathBuf::from("/project/worker.ts"),
1291                size_bytes: 100,
1292            },
1293            DiscoveredFile {
1294                id: FileId(2),
1295                path: PathBuf::from("/project/shared.ts"),
1296                size_bytes: 50,
1297            },
1298        ];
1299        let entry_points = vec![
1300            EntryPoint {
1301                path: PathBuf::from("/project/main.ts"),
1302                source: EntryPointSource::PackageJsonMain,
1303            },
1304            EntryPoint {
1305                path: PathBuf::from("/project/worker.ts"),
1306                source: EntryPointSource::PackageJsonMain,
1307            },
1308        ];
1309        let resolved_modules = vec![
1310            ResolvedModule {
1311                file_id: FileId(0),
1312                path: PathBuf::from("/project/main.ts"),
1313                resolved_imports: vec![ResolvedImport {
1314                    info: ImportInfo {
1315                        source: "./shared".to_string(),
1316                        imported_name: ImportedName::Named("helper".to_string()),
1317                        local_name: "helper".to_string(),
1318                        is_type_only: false,
1319                        from_style: false,
1320                        span: oxc_span::Span::new(0, 10),
1321                        source_span: oxc_span::Span::default(),
1322                    },
1323                    target: ResolveResult::InternalModule(FileId(2)),
1324                }],
1325                ..Default::default()
1326            },
1327            ResolvedModule {
1328                file_id: FileId(1),
1329                path: PathBuf::from("/project/worker.ts"),
1330                ..Default::default()
1331            },
1332            ResolvedModule {
1333                file_id: FileId(2),
1334                path: PathBuf::from("/project/shared.ts"),
1335                exports: vec![fallow_types::extract::ExportInfo {
1336                    name: ExportName::Named("helper".to_string()),
1337                    local_name: Some("helper".to_string()),
1338                    is_type_only: false,
1339                    visibility: VisibilityTag::None,
1340                    expected_unused_reason: None,
1341                    span: oxc_span::Span::new(0, 20),
1342                    members: vec![],
1343                    is_side_effect_used: false,
1344                    super_class: None,
1345                }],
1346                ..Default::default()
1347            },
1348        ];
1349
1350        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1351        assert!(graph.modules[0].is_entry_point());
1352        assert!(graph.modules[1].is_entry_point());
1353        assert!(!graph.modules[2].is_entry_point());
1354        assert!(graph.modules[0].is_reachable());
1355        assert!(graph.modules[1].is_reachable());
1356        assert!(graph.modules[2].is_reachable());
1357    }
1358}