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                        span: oxc_span::Span::new(0, 20),
466                        members: vec![],
467                        is_side_effect_used: false,
468                        super_class: None,
469                    },
470                    fallow_types::extract::ExportInfo {
471                        name: ExportName::Named("bar".to_string()),
472                        local_name: Some("bar".to_string()),
473                        is_type_only: false,
474                        visibility: VisibilityTag::None,
475                        span: oxc_span::Span::new(25, 45),
476                        members: vec![],
477                        is_side_effect_used: false,
478                        super_class: None,
479                    },
480                ],
481                ..Default::default()
482            },
483        ];
484
485        ModuleGraph::build(&resolved_modules, &entry_points, &files)
486    }
487
488    #[test]
489    fn graph_module_count() {
490        let graph = build_simple_graph();
491        assert_eq!(graph.module_count(), 2);
492    }
493
494    #[test]
495    fn graph_edge_count() {
496        let graph = build_simple_graph();
497        assert_eq!(graph.edge_count(), 1);
498    }
499
500    #[test]
501    fn graph_entry_point_is_reachable() {
502        let graph = build_simple_graph();
503        assert!(graph.modules[0].is_entry_point());
504        assert!(graph.modules[0].is_reachable());
505    }
506
507    #[test]
508    fn graph_imported_module_is_reachable() {
509        let graph = build_simple_graph();
510        assert!(!graph.modules[1].is_entry_point());
511        assert!(graph.modules[1].is_reachable());
512    }
513
514    #[test]
515    #[expect(
516        clippy::too_many_lines,
517        reason = "this test fixture exercises four reachability roles end-to-end; splitting it \
518                  would obscure the cross-role assertions"
519    )]
520    fn graph_distinguishes_runtime_test_and_support_reachability() {
521        let files = vec![
522            DiscoveredFile {
523                id: FileId(0),
524                path: PathBuf::from("/project/src/main.ts"),
525                size_bytes: 100,
526            },
527            DiscoveredFile {
528                id: FileId(1),
529                path: PathBuf::from("/project/src/runtime-only.ts"),
530                size_bytes: 50,
531            },
532            DiscoveredFile {
533                id: FileId(2),
534                path: PathBuf::from("/project/tests/app.test.ts"),
535                size_bytes: 50,
536            },
537            DiscoveredFile {
538                id: FileId(3),
539                path: PathBuf::from("/project/tests/setup.ts"),
540                size_bytes: 50,
541            },
542            DiscoveredFile {
543                id: FileId(4),
544                path: PathBuf::from("/project/src/covered.ts"),
545                size_bytes: 50,
546            },
547        ];
548
549        let all_entry_points = vec![
550            EntryPoint {
551                path: PathBuf::from("/project/src/main.ts"),
552                source: EntryPointSource::PackageJsonMain,
553            },
554            EntryPoint {
555                path: PathBuf::from("/project/tests/app.test.ts"),
556                source: EntryPointSource::TestFile,
557            },
558            EntryPoint {
559                path: PathBuf::from("/project/tests/setup.ts"),
560                source: EntryPointSource::Plugin {
561                    name: "vitest".to_string(),
562                },
563            },
564        ];
565        let runtime_entry_points = vec![EntryPoint {
566            path: PathBuf::from("/project/src/main.ts"),
567            source: EntryPointSource::PackageJsonMain,
568        }];
569        let test_entry_points = vec![EntryPoint {
570            path: PathBuf::from("/project/tests/app.test.ts"),
571            source: EntryPointSource::TestFile,
572        }];
573
574        let resolved_modules = vec![
575            ResolvedModule {
576                file_id: FileId(0),
577                path: PathBuf::from("/project/src/main.ts"),
578                resolved_imports: vec![ResolvedImport {
579                    info: ImportInfo {
580                        source: "./runtime-only".to_string(),
581                        imported_name: ImportedName::Named("runtimeOnly".to_string()),
582                        local_name: "runtimeOnly".to_string(),
583                        is_type_only: false,
584                        from_style: false,
585                        span: oxc_span::Span::new(0, 10),
586                        source_span: oxc_span::Span::default(),
587                    },
588                    target: ResolveResult::InternalModule(FileId(1)),
589                }],
590                ..Default::default()
591            },
592            ResolvedModule {
593                file_id: FileId(1),
594                path: PathBuf::from("/project/src/runtime-only.ts"),
595                exports: vec![fallow_types::extract::ExportInfo {
596                    name: ExportName::Named("runtimeOnly".to_string()),
597                    local_name: Some("runtimeOnly".to_string()),
598                    is_type_only: false,
599                    visibility: VisibilityTag::None,
600                    span: oxc_span::Span::new(0, 20),
601                    members: vec![],
602                    is_side_effect_used: false,
603                    super_class: None,
604                }],
605                ..Default::default()
606            },
607            ResolvedModule {
608                file_id: FileId(2),
609                path: PathBuf::from("/project/tests/app.test.ts"),
610                resolved_imports: vec![ResolvedImport {
611                    info: ImportInfo {
612                        source: "../src/covered".to_string(),
613                        imported_name: ImportedName::Named("covered".to_string()),
614                        local_name: "covered".to_string(),
615                        is_type_only: false,
616                        from_style: false,
617                        span: oxc_span::Span::new(0, 10),
618                        source_span: oxc_span::Span::default(),
619                    },
620                    target: ResolveResult::InternalModule(FileId(4)),
621                }],
622                ..Default::default()
623            },
624            ResolvedModule {
625                file_id: FileId(3),
626                path: PathBuf::from("/project/tests/setup.ts"),
627                resolved_imports: vec![ResolvedImport {
628                    info: ImportInfo {
629                        source: "../src/runtime-only".to_string(),
630                        imported_name: ImportedName::Named("runtimeOnly".to_string()),
631                        local_name: "runtimeOnly".to_string(),
632                        is_type_only: false,
633                        from_style: false,
634                        span: oxc_span::Span::new(0, 10),
635                        source_span: oxc_span::Span::default(),
636                    },
637                    target: ResolveResult::InternalModule(FileId(1)),
638                }],
639                ..Default::default()
640            },
641            ResolvedModule {
642                file_id: FileId(4),
643                path: PathBuf::from("/project/src/covered.ts"),
644                exports: vec![fallow_types::extract::ExportInfo {
645                    name: ExportName::Named("covered".to_string()),
646                    local_name: Some("covered".to_string()),
647                    is_type_only: false,
648                    visibility: VisibilityTag::None,
649                    span: oxc_span::Span::new(0, 20),
650                    members: vec![],
651                    is_side_effect_used: false,
652                    super_class: None,
653                }],
654                ..Default::default()
655            },
656        ];
657
658        let graph = ModuleGraph::build_with_reachability_roots(
659            &resolved_modules,
660            &all_entry_points,
661            &runtime_entry_points,
662            &test_entry_points,
663            &files,
664        );
665
666        assert!(graph.modules[1].is_reachable());
667        assert!(graph.modules[1].is_runtime_reachable());
668        assert!(
669            !graph.modules[1].is_test_reachable(),
670            "support roots should not make runtime-only modules test reachable"
671        );
672
673        assert!(graph.modules[4].is_reachable());
674        assert!(graph.modules[4].is_test_reachable());
675        assert!(
676            !graph.modules[4].is_runtime_reachable(),
677            "test-only reachability should stay separate from runtime roots"
678        );
679    }
680
681    #[test]
682    fn graph_export_has_reference() {
683        let graph = build_simple_graph();
684        let utils = &graph.modules[1];
685        let foo_export = utils
686            .exports
687            .iter()
688            .find(|e| e.name.to_string() == "foo")
689            .unwrap();
690        assert!(
691            !foo_export.references.is_empty(),
692            "foo should have references"
693        );
694    }
695
696    #[test]
697    fn graph_unused_export_no_reference() {
698        let graph = build_simple_graph();
699        let utils = &graph.modules[1];
700        let bar_export = utils
701            .exports
702            .iter()
703            .find(|e| e.name.to_string() == "bar")
704            .unwrap();
705        assert!(
706            bar_export.references.is_empty(),
707            "bar should have no references"
708        );
709    }
710
711    #[test]
712    fn graph_no_namespace_import() {
713        let graph = build_simple_graph();
714        assert!(!graph.has_namespace_import(FileId(0)));
715        assert!(!graph.has_namespace_import(FileId(1)));
716    }
717
718    #[test]
719    fn graph_has_namespace_import() {
720        let files = vec![
721            DiscoveredFile {
722                id: FileId(0),
723                path: PathBuf::from("/project/entry.ts"),
724                size_bytes: 100,
725            },
726            DiscoveredFile {
727                id: FileId(1),
728                path: PathBuf::from("/project/utils.ts"),
729                size_bytes: 50,
730            },
731        ];
732
733        let entry_points = vec![EntryPoint {
734            path: PathBuf::from("/project/entry.ts"),
735            source: EntryPointSource::PackageJsonMain,
736        }];
737
738        let resolved_modules = vec![
739            ResolvedModule {
740                file_id: FileId(0),
741                path: PathBuf::from("/project/entry.ts"),
742                resolved_imports: vec![ResolvedImport {
743                    info: ImportInfo {
744                        source: "./utils".to_string(),
745                        imported_name: ImportedName::Namespace,
746                        local_name: "utils".to_string(),
747                        is_type_only: false,
748                        from_style: false,
749                        span: oxc_span::Span::new(0, 10),
750                        source_span: oxc_span::Span::default(),
751                    },
752                    target: ResolveResult::InternalModule(FileId(1)),
753                }],
754                ..Default::default()
755            },
756            ResolvedModule {
757                file_id: FileId(1),
758                path: PathBuf::from("/project/utils.ts"),
759                exports: vec![fallow_types::extract::ExportInfo {
760                    name: ExportName::Named("foo".to_string()),
761                    local_name: Some("foo".to_string()),
762                    is_type_only: false,
763                    visibility: VisibilityTag::None,
764                    span: oxc_span::Span::new(0, 20),
765                    members: vec![],
766                    is_side_effect_used: false,
767                    super_class: None,
768                }],
769                ..Default::default()
770            },
771        ];
772
773        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
774        assert!(
775            graph.has_namespace_import(FileId(1)),
776            "utils should have namespace import"
777        );
778    }
779
780    #[test]
781    fn graph_has_namespace_import_out_of_bounds() {
782        let graph = build_simple_graph();
783        assert!(!graph.has_namespace_import(FileId(999)));
784    }
785
786    #[test]
787    fn graph_unreachable_module() {
788        let files = vec![
789            DiscoveredFile {
790                id: FileId(0),
791                path: PathBuf::from("/project/entry.ts"),
792                size_bytes: 100,
793            },
794            DiscoveredFile {
795                id: FileId(1),
796                path: PathBuf::from("/project/utils.ts"),
797                size_bytes: 50,
798            },
799            DiscoveredFile {
800                id: FileId(2),
801                path: PathBuf::from("/project/orphan.ts"),
802                size_bytes: 30,
803            },
804        ];
805
806        let entry_points = vec![EntryPoint {
807            path: PathBuf::from("/project/entry.ts"),
808            source: EntryPointSource::PackageJsonMain,
809        }];
810
811        let resolved_modules = vec![
812            ResolvedModule {
813                file_id: FileId(0),
814                path: PathBuf::from("/project/entry.ts"),
815                resolved_imports: vec![ResolvedImport {
816                    info: ImportInfo {
817                        source: "./utils".to_string(),
818                        imported_name: ImportedName::Named("foo".to_string()),
819                        local_name: "foo".to_string(),
820                        is_type_only: false,
821                        from_style: false,
822                        span: oxc_span::Span::new(0, 10),
823                        source_span: oxc_span::Span::default(),
824                    },
825                    target: ResolveResult::InternalModule(FileId(1)),
826                }],
827                ..Default::default()
828            },
829            ResolvedModule {
830                file_id: FileId(1),
831                path: PathBuf::from("/project/utils.ts"),
832                exports: vec![fallow_types::extract::ExportInfo {
833                    name: ExportName::Named("foo".to_string()),
834                    local_name: Some("foo".to_string()),
835                    is_type_only: false,
836                    visibility: VisibilityTag::None,
837                    span: oxc_span::Span::new(0, 20),
838                    members: vec![],
839                    is_side_effect_used: false,
840                    super_class: None,
841                }],
842                ..Default::default()
843            },
844            ResolvedModule {
845                file_id: FileId(2),
846                path: PathBuf::from("/project/orphan.ts"),
847                exports: vec![fallow_types::extract::ExportInfo {
848                    name: ExportName::Named("orphan".to_string()),
849                    local_name: Some("orphan".to_string()),
850                    is_type_only: false,
851                    visibility: VisibilityTag::None,
852                    span: oxc_span::Span::new(0, 20),
853                    members: vec![],
854                    is_side_effect_used: false,
855                    super_class: None,
856                }],
857                ..Default::default()
858            },
859        ];
860
861        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
862
863        assert!(graph.modules[0].is_reachable(), "entry should be reachable");
864        assert!(graph.modules[1].is_reachable(), "utils should be reachable");
865        assert!(
866            !graph.modules[2].is_reachable(),
867            "orphan should NOT be reachable"
868        );
869    }
870
871    #[test]
872    fn graph_package_usage_tracked() {
873        let files = vec![DiscoveredFile {
874            id: FileId(0),
875            path: PathBuf::from("/project/entry.ts"),
876            size_bytes: 100,
877        }];
878
879        let entry_points = vec![EntryPoint {
880            path: PathBuf::from("/project/entry.ts"),
881            source: EntryPointSource::PackageJsonMain,
882        }];
883
884        let resolved_modules = vec![ResolvedModule {
885            file_id: FileId(0),
886            path: PathBuf::from("/project/entry.ts"),
887            exports: vec![],
888            re_exports: vec![],
889            resolved_imports: vec![
890                ResolvedImport {
891                    info: ImportInfo {
892                        source: "react".to_string(),
893                        imported_name: ImportedName::Default,
894                        local_name: "React".to_string(),
895                        is_type_only: false,
896                        from_style: false,
897                        span: oxc_span::Span::new(0, 10),
898                        source_span: oxc_span::Span::default(),
899                    },
900                    target: ResolveResult::NpmPackage("react".to_string()),
901                },
902                ResolvedImport {
903                    info: ImportInfo {
904                        source: "lodash".to_string(),
905                        imported_name: ImportedName::Named("merge".to_string()),
906                        local_name: "merge".to_string(),
907                        is_type_only: false,
908                        from_style: false,
909                        span: oxc_span::Span::new(15, 30),
910                        source_span: oxc_span::Span::default(),
911                    },
912                    target: ResolveResult::NpmPackage("lodash".to_string()),
913                },
914            ],
915            ..Default::default()
916        }];
917
918        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
919        assert!(graph.package_usage.contains_key("react"));
920        assert!(graph.package_usage.contains_key("lodash"));
921        assert!(!graph.package_usage.contains_key("express"));
922    }
923
924    #[test]
925    fn graph_empty() {
926        let graph = ModuleGraph::build(&[], &[], &[]);
927        assert_eq!(graph.module_count(), 0);
928        assert_eq!(graph.edge_count(), 0);
929    }
930
931    #[test]
932    fn graph_cjs_exports_tracked() {
933        let files = vec![DiscoveredFile {
934            id: FileId(0),
935            path: PathBuf::from("/project/entry.ts"),
936            size_bytes: 100,
937        }];
938
939        let entry_points = vec![EntryPoint {
940            path: PathBuf::from("/project/entry.ts"),
941            source: EntryPointSource::PackageJsonMain,
942        }];
943
944        let resolved_modules = vec![ResolvedModule {
945            file_id: FileId(0),
946            path: PathBuf::from("/project/entry.ts"),
947            has_cjs_exports: true,
948            has_angular_component_template_url: false,
949            ..Default::default()
950        }];
951
952        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
953        assert!(graph.modules[0].has_cjs_exports());
954    }
955
956    #[test]
957    fn graph_edges_for_returns_targets() {
958        let graph = build_simple_graph();
959        let targets = graph.edges_for(FileId(0));
960        assert_eq!(targets, vec![FileId(1)]);
961    }
962
963    #[test]
964    fn graph_edges_for_no_imports() {
965        let graph = build_simple_graph();
966        let targets = graph.edges_for(FileId(1));
967        assert!(targets.is_empty());
968    }
969
970    #[test]
971    fn graph_edges_for_out_of_bounds() {
972        let graph = build_simple_graph();
973        let targets = graph.edges_for(FileId(999));
974        assert!(targets.is_empty());
975    }
976
977    #[test]
978    fn graph_direct_importer_summaries_include_symbols() {
979        let graph = build_simple_graph();
980        let summaries = graph.direct_importer_summaries(FileId(1));
981
982        assert_eq!(
983            summaries,
984            vec![DirectImporterSummary {
985                source: FileId(0),
986                symbols: vec![ImportedSymbolSummary {
987                    imported: "foo".to_string(),
988                    local: "foo".to_string(),
989                    type_only: false,
990                }],
991            }]
992        );
993    }
994
995    #[test]
996    fn graph_find_import_span_start_found() {
997        let graph = build_simple_graph();
998        let span_start = graph.find_import_span_start(FileId(0), FileId(1));
999        assert!(span_start.is_some());
1000        assert_eq!(span_start.unwrap(), 0);
1001    }
1002
1003    #[test]
1004    fn graph_find_import_span_start_prefers_value_import_on_mixed_edge() {
1005        let files = vec![
1006            DiscoveredFile {
1007                id: FileId(0),
1008                path: PathBuf::from("/project/entry.ts"),
1009                size_bytes: 100,
1010            },
1011            DiscoveredFile {
1012                id: FileId(1),
1013                path: PathBuf::from("/project/utils.ts"),
1014                size_bytes: 50,
1015            },
1016        ];
1017        let entry_points = vec![EntryPoint {
1018            path: PathBuf::from("/project/entry.ts"),
1019            source: EntryPointSource::PackageJsonMain,
1020        }];
1021        let resolved_modules = vec![
1022            ResolvedModule {
1023                file_id: FileId(0),
1024                path: PathBuf::from("/project/entry.ts"),
1025                resolved_imports: vec![
1026                    ResolvedImport {
1027                        info: ImportInfo {
1028                            source: "./utils".to_string(),
1029                            imported_name: ImportedName::Named("Foo".to_string()),
1030                            local_name: "Foo".to_string(),
1031                            is_type_only: true,
1032                            from_style: false,
1033                            span: oxc_span::Span::new(10, 20),
1034                            source_span: oxc_span::Span::default(),
1035                        },
1036                        target: ResolveResult::InternalModule(FileId(1)),
1037                    },
1038                    ResolvedImport {
1039                        info: ImportInfo {
1040                            source: "./utils".to_string(),
1041                            imported_name: ImportedName::Named("foo".to_string()),
1042                            local_name: "foo".to_string(),
1043                            is_type_only: false,
1044                            from_style: false,
1045                            span: oxc_span::Span::new(50, 60),
1046                            source_span: oxc_span::Span::default(),
1047                        },
1048                        target: ResolveResult::InternalModule(FileId(1)),
1049                    },
1050                ],
1051                ..Default::default()
1052            },
1053            ResolvedModule {
1054                file_id: FileId(1),
1055                path: PathBuf::from("/project/utils.ts"),
1056                ..Default::default()
1057            },
1058        ];
1059
1060        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1061        assert_eq!(graph.find_import_span_start(FileId(0), FileId(1)), Some(50));
1062    }
1063
1064    #[test]
1065    fn graph_find_import_span_start_wrong_target() {
1066        let graph = build_simple_graph();
1067        let span_start = graph.find_import_span_start(FileId(0), FileId(0));
1068        assert!(span_start.is_none());
1069    }
1070
1071    #[test]
1072    fn graph_find_import_span_start_source_out_of_bounds() {
1073        let graph = build_simple_graph();
1074        let span_start = graph.find_import_span_start(FileId(999), FileId(1));
1075        assert!(span_start.is_none());
1076    }
1077
1078    #[test]
1079    fn graph_find_import_span_start_no_edges() {
1080        let graph = build_simple_graph();
1081        let span_start = graph.find_import_span_start(FileId(1), FileId(0));
1082        assert!(span_start.is_none());
1083    }
1084
1085    #[test]
1086    fn graph_reverse_deps_populated() {
1087        let graph = build_simple_graph();
1088        assert!(graph.reverse_deps[1].contains(&FileId(0)));
1089        assert!(graph.reverse_deps[0].is_empty());
1090    }
1091
1092    #[test]
1093    fn graph_type_only_package_usage_tracked() {
1094        let files = vec![DiscoveredFile {
1095            id: FileId(0),
1096            path: PathBuf::from("/project/entry.ts"),
1097            size_bytes: 100,
1098        }];
1099        let entry_points = vec![EntryPoint {
1100            path: PathBuf::from("/project/entry.ts"),
1101            source: EntryPointSource::PackageJsonMain,
1102        }];
1103        let resolved_modules = vec![ResolvedModule {
1104            file_id: FileId(0),
1105            path: PathBuf::from("/project/entry.ts"),
1106            resolved_imports: vec![
1107                ResolvedImport {
1108                    info: ImportInfo {
1109                        source: "react".to_string(),
1110                        imported_name: ImportedName::Named("FC".to_string()),
1111                        local_name: "FC".to_string(),
1112                        is_type_only: true,
1113                        from_style: false,
1114                        span: oxc_span::Span::new(0, 10),
1115                        source_span: oxc_span::Span::default(),
1116                    },
1117                    target: ResolveResult::NpmPackage("react".to_string()),
1118                },
1119                ResolvedImport {
1120                    info: ImportInfo {
1121                        source: "react".to_string(),
1122                        imported_name: ImportedName::Named("useState".to_string()),
1123                        local_name: "useState".to_string(),
1124                        is_type_only: false,
1125                        from_style: false,
1126                        span: oxc_span::Span::new(15, 30),
1127                        source_span: oxc_span::Span::default(),
1128                    },
1129                    target: ResolveResult::NpmPackage("react".to_string()),
1130                },
1131            ],
1132            ..Default::default()
1133        }];
1134
1135        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1136        assert!(graph.package_usage.contains_key("react"));
1137        assert!(graph.type_only_package_usage.contains_key("react"));
1138    }
1139
1140    #[test]
1141    fn graph_default_import_reference() {
1142        let files = vec![
1143            DiscoveredFile {
1144                id: FileId(0),
1145                path: PathBuf::from("/project/entry.ts"),
1146                size_bytes: 100,
1147            },
1148            DiscoveredFile {
1149                id: FileId(1),
1150                path: PathBuf::from("/project/utils.ts"),
1151                size_bytes: 50,
1152            },
1153        ];
1154        let entry_points = vec![EntryPoint {
1155            path: PathBuf::from("/project/entry.ts"),
1156            source: EntryPointSource::PackageJsonMain,
1157        }];
1158        let resolved_modules = vec![
1159            ResolvedModule {
1160                file_id: FileId(0),
1161                path: PathBuf::from("/project/entry.ts"),
1162                resolved_imports: vec![ResolvedImport {
1163                    info: ImportInfo {
1164                        source: "./utils".to_string(),
1165                        imported_name: ImportedName::Default,
1166                        local_name: "Utils".to_string(),
1167                        is_type_only: false,
1168                        from_style: false,
1169                        span: oxc_span::Span::new(0, 10),
1170                        source_span: oxc_span::Span::default(),
1171                    },
1172                    target: ResolveResult::InternalModule(FileId(1)),
1173                }],
1174                ..Default::default()
1175            },
1176            ResolvedModule {
1177                file_id: FileId(1),
1178                path: PathBuf::from("/project/utils.ts"),
1179                exports: vec![fallow_types::extract::ExportInfo {
1180                    name: ExportName::Default,
1181                    local_name: None,
1182                    is_type_only: false,
1183                    visibility: VisibilityTag::None,
1184                    span: oxc_span::Span::new(0, 20),
1185                    members: vec![],
1186                    is_side_effect_used: false,
1187                    super_class: None,
1188                }],
1189                ..Default::default()
1190            },
1191        ];
1192
1193        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1194        let utils = &graph.modules[1];
1195        let default_export = utils
1196            .exports
1197            .iter()
1198            .find(|e| matches!(e.name, ExportName::Default))
1199            .unwrap();
1200        assert!(!default_export.references.is_empty());
1201        assert_eq!(
1202            default_export.references[0].kind,
1203            ReferenceKind::DefaultImport
1204        );
1205    }
1206
1207    #[test]
1208    fn graph_side_effect_import_no_export_reference() {
1209        let files = vec![
1210            DiscoveredFile {
1211                id: FileId(0),
1212                path: PathBuf::from("/project/entry.ts"),
1213                size_bytes: 100,
1214            },
1215            DiscoveredFile {
1216                id: FileId(1),
1217                path: PathBuf::from("/project/styles.ts"),
1218                size_bytes: 50,
1219            },
1220        ];
1221        let entry_points = vec![EntryPoint {
1222            path: PathBuf::from("/project/entry.ts"),
1223            source: EntryPointSource::PackageJsonMain,
1224        }];
1225        let resolved_modules = vec![
1226            ResolvedModule {
1227                file_id: FileId(0),
1228                path: PathBuf::from("/project/entry.ts"),
1229                resolved_imports: vec![ResolvedImport {
1230                    info: ImportInfo {
1231                        source: "./styles".to_string(),
1232                        imported_name: ImportedName::SideEffect,
1233                        local_name: String::new(),
1234                        is_type_only: false,
1235                        from_style: false,
1236                        span: oxc_span::Span::new(0, 10),
1237                        source_span: oxc_span::Span::default(),
1238                    },
1239                    target: ResolveResult::InternalModule(FileId(1)),
1240                }],
1241                ..Default::default()
1242            },
1243            ResolvedModule {
1244                file_id: FileId(1),
1245                path: PathBuf::from("/project/styles.ts"),
1246                exports: vec![fallow_types::extract::ExportInfo {
1247                    name: ExportName::Named("primaryColor".to_string()),
1248                    local_name: Some("primaryColor".to_string()),
1249                    is_type_only: false,
1250                    visibility: VisibilityTag::None,
1251                    span: oxc_span::Span::new(0, 20),
1252                    members: vec![],
1253                    is_side_effect_used: false,
1254                    super_class: None,
1255                }],
1256                ..Default::default()
1257            },
1258        ];
1259
1260        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1261        assert_eq!(graph.edge_count(), 1);
1262        let styles = &graph.modules[1];
1263        let export = &styles.exports[0];
1264        assert!(
1265            export.references.is_empty(),
1266            "side-effect import should not reference named exports"
1267        );
1268    }
1269
1270    #[test]
1271    fn graph_multiple_entry_points() {
1272        let files = vec![
1273            DiscoveredFile {
1274                id: FileId(0),
1275                path: PathBuf::from("/project/main.ts"),
1276                size_bytes: 100,
1277            },
1278            DiscoveredFile {
1279                id: FileId(1),
1280                path: PathBuf::from("/project/worker.ts"),
1281                size_bytes: 100,
1282            },
1283            DiscoveredFile {
1284                id: FileId(2),
1285                path: PathBuf::from("/project/shared.ts"),
1286                size_bytes: 50,
1287            },
1288        ];
1289        let entry_points = vec![
1290            EntryPoint {
1291                path: PathBuf::from("/project/main.ts"),
1292                source: EntryPointSource::PackageJsonMain,
1293            },
1294            EntryPoint {
1295                path: PathBuf::from("/project/worker.ts"),
1296                source: EntryPointSource::PackageJsonMain,
1297            },
1298        ];
1299        let resolved_modules = vec![
1300            ResolvedModule {
1301                file_id: FileId(0),
1302                path: PathBuf::from("/project/main.ts"),
1303                resolved_imports: vec![ResolvedImport {
1304                    info: ImportInfo {
1305                        source: "./shared".to_string(),
1306                        imported_name: ImportedName::Named("helper".to_string()),
1307                        local_name: "helper".to_string(),
1308                        is_type_only: false,
1309                        from_style: false,
1310                        span: oxc_span::Span::new(0, 10),
1311                        source_span: oxc_span::Span::default(),
1312                    },
1313                    target: ResolveResult::InternalModule(FileId(2)),
1314                }],
1315                ..Default::default()
1316            },
1317            ResolvedModule {
1318                file_id: FileId(1),
1319                path: PathBuf::from("/project/worker.ts"),
1320                ..Default::default()
1321            },
1322            ResolvedModule {
1323                file_id: FileId(2),
1324                path: PathBuf::from("/project/shared.ts"),
1325                exports: vec![fallow_types::extract::ExportInfo {
1326                    name: ExportName::Named("helper".to_string()),
1327                    local_name: Some("helper".to_string()),
1328                    is_type_only: false,
1329                    visibility: VisibilityTag::None,
1330                    span: oxc_span::Span::new(0, 20),
1331                    members: vec![],
1332                    is_side_effect_used: false,
1333                    super_class: None,
1334                }],
1335                ..Default::default()
1336            },
1337        ];
1338
1339        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1340        assert!(graph.modules[0].is_entry_point());
1341        assert!(graph.modules[1].is_entry_point());
1342        assert!(!graph.modules[2].is_entry_point());
1343        assert!(graph.modules[0].is_reachable());
1344        assert!(graph.modules[1].is_reachable());
1345        assert!(graph.modules[2].is_reachable());
1346    }
1347}