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