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 fan_io;
9mod impact_closure;
10mod namespace_aliases;
11mod namespace_re_exports;
12mod narrowing;
13mod partition_order;
14mod public_exports;
15mod re_export_reachability;
16mod re_exports;
17mod reachability;
18pub mod types;
19
20use std::path::Path;
21
22use fixedbitset::FixedBitSet;
23use rustc_hash::{FxHashMap, FxHashSet};
24
25use crate::resolve::ResolvedModule;
26use fallow_types::discover::{DiscoveredFile, EntryPoint, FileId};
27use fallow_types::extract::ImportedName;
28
29pub use fan_io::{FocusFileFacts, FocusFileFactsPaths};
30pub use impact_closure::{
31    CoordinationGap, CoordinationGapPaths, ImpactClosure, ImpactClosurePaths,
32};
33pub use partition_order::{PartitionOrder, PartitionOrderPaths, ReviewUnit, ReviewUnitPaths};
34pub use re_exports::GraphReExportCycle;
35pub use types::{ExportSymbol, ModuleNode, ReExportEdge, ReferenceKind, SymbolReference};
36
37/// True when the path's final component looks like a TypeScript declaration
38/// file (`.d.ts`, `.d.mts`, `.d.cts`). Used to seed declaration files as
39/// overall entry points so ambient `typeof import()` references stay alive.
40///
41/// Keep in sync with `fallow_core::analyze::predicates::is_declaration_file`;
42/// the graph crate cannot depend on core, so the predicate is duplicated.
43fn is_declaration_file_path(path: &Path) -> bool {
44    path.file_name()
45        .and_then(|n| n.to_str())
46        .is_some_and(|name| {
47            name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
48        })
49}
50
51/// The core module dependency graph.
52///
53/// Derives `serde` so the whole graph can be persisted to `.fallow/graph-cache.bin`
54/// (see `crate::cache`) and skipped on a re-run whose inputs are byte-identical.
55/// `namespace_imported` is a derived `FixedBitSet` reconstructed from the edge
56/// set on cache load (`reconstruct_namespace_imported`), so it is
57/// `#[serde(skip, default)]` rather than persisted.
58#[derive(Debug, serde::Serialize, serde::Deserialize)]
59pub struct ModuleGraph {
60    /// All modules indexed by `FileId`.
61    ///
62    /// Invariant: `modules[file_id.0 as usize].file_id == file_id` for every
63    /// `FileId` in the graph. Holds because `discover/walk.rs` assigns FileIds
64    /// sequentially via `.enumerate()` after path-sorting, and
65    /// `build::populate_edges` pushes one `ModuleNode` per file in iteration
66    /// order. Detectors rely on this for O(1) FileId-to-module lookup
67    /// (`graph.modules.get(file_id.0 as usize)`) instead of building a
68    /// per-call `FxHashMap<FileId, &ModuleNode>`.
69    pub modules: Vec<ModuleNode>,
70    /// Flat edge storage for cache-friendly iteration.
71    edges: Vec<Edge>,
72    /// Maps npm package names to the set of `FileId`s that import them.
73    pub package_usage: FxHashMap<String, Vec<FileId>>,
74    /// Maps npm package names to the set of `FileId`s that import them with type-only imports.
75    /// A package appearing here but not in `package_usage` (or only in both) indicates
76    /// it's only used for types and could be a devDependency.
77    pub type_only_package_usage: FxHashMap<String, Vec<FileId>>,
78    /// All entry point `FileId`s.
79    pub entry_points: FxHashSet<FileId>,
80    /// Runtime/application entry point `FileId`s.
81    pub runtime_entry_points: FxHashSet<FileId>,
82    /// Test entry point `FileId`s.
83    pub test_entry_points: FxHashSet<FileId>,
84    /// Reverse index: for each `FileId`, which files import it.
85    pub reverse_deps: Vec<Vec<FileId>>,
86    /// Precomputed: which modules have namespace imports (import * as ns).
87    ///
88    /// Derived entirely from the edge set (a module is namespace-imported iff
89    /// some edge to it carries an `ImportedName::Namespace` symbol), so it is
90    /// not persisted: on cache load it is rebuilt by
91    /// [`ModuleGraph::reconstruct_namespace_imported`], which replicates the
92    /// exact insertion logic from `build.rs`.
93    #[serde(skip, default)]
94    namespace_imported: FixedBitSet,
95    /// Re-export cycles and self-loops detected during Phase 4 chain
96    /// resolution. Each entry names the participating files (sorted
97    /// lexicographically) and a `is_self_loop` flag distinguishing
98    /// single-file self-re-exports from multi-node cycles. Populated by
99    /// `re_exports::find_re_export_cycles` and consumed by
100    /// `fallow_core::analyze::re_export_cycles::find_re_export_cycles` which
101    /// wraps each entry in a typed `ReExportCycleFinding`.
102    pub re_export_cycles: Vec<GraphReExportCycle>,
103}
104
105/// An edge in the module graph.
106///
107/// Public surface: `fallow trace` walks the raw per-symbol `imported_name`
108/// / `local_name` in BOTH directions (callers via `reverse_deps`, callees via
109/// outgoing edges), which the flattened summary structs cannot express. The
110/// field layout (and the `Edge == 32` size assertion below) is unchanged by the
111/// visibility widen.
112#[derive(Debug, serde::Serialize, serde::Deserialize)]
113pub struct Edge {
114    /// Source module of this import edge.
115    pub source: FileId,
116    /// Target module imported by `source`.
117    pub target: FileId,
118    /// Symbols imported across this edge.
119    pub symbols: Vec<ImportedSymbol>,
120}
121
122/// A symbol imported across an edge.
123#[derive(Debug, serde::Serialize, serde::Deserialize)]
124pub struct ImportedSymbol {
125    /// The name as imported from the target (`Named`, `Default`, `Namespace`,
126    /// `SideEffect`).
127    pub imported_name: ImportedName,
128    /// Local binding name in the importing file.
129    pub local_name: String,
130    /// Byte span of the import statement in the source file.
131    #[serde(with = "crate::cache::span_serde")]
132    pub import_span: oxc_span::Span,
133    /// Whether this import is type-only (`import type { ... }`).
134    /// Used to skip type-only edges in circular dependency detection.
135    pub is_type_only: bool,
136}
137
138/// Importer details for one file that directly imports a target module.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct DirectImporterSummary {
141    /// Source file that imports the requested target.
142    pub source: FileId,
143    /// Symbols imported from the target by this source file.
144    pub symbols: Vec<ImportedSymbolSummary>,
145}
146
147/// Symbol details for a direct import edge.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct ImportedSymbolSummary {
150    /// Imported binding name, using `default`, `*`, and `side-effect` for
151    /// non-named imports.
152    pub imported: String,
153    /// Local binding name in the importing file.
154    pub local: String,
155    /// Whether this symbol came from a type-only import.
156    pub type_only: bool,
157}
158
159#[cfg(target_pointer_width = "64")]
160const _: () = assert!(std::mem::size_of::<Edge>() == 32);
161#[cfg(target_pointer_width = "64")]
162const _: () = assert!(std::mem::size_of::<ImportedSymbol>() == 64);
163
164impl ModuleGraph {
165    fn resolve_entry_point_ids(
166        entry_points: &[EntryPoint],
167        path_to_id: &FxHashMap<&Path, FileId>,
168    ) -> FxHashSet<FileId> {
169        entry_points
170            .iter()
171            .filter_map(|ep| {
172                path_to_id.get(ep.path.as_path()).copied().or_else(|| {
173                    dunce::canonicalize(&ep.path)
174                        .ok()
175                        .and_then(|path| path_to_id.get(path.as_path()).copied())
176                })
177            })
178            .collect()
179    }
180
181    /// Build the module graph from resolved modules and entry points.
182    pub fn build(
183        resolved_modules: &[ResolvedModule],
184        entry_points: &[EntryPoint],
185        files: &[DiscoveredFile],
186    ) -> Self {
187        Self::build_with_reachability_roots(
188            resolved_modules,
189            entry_points,
190            entry_points,
191            &[],
192            files,
193        )
194    }
195
196    /// Build the module graph with explicit runtime and test reachability roots.
197    pub fn build_with_reachability_roots(
198        resolved_modules: &[ResolvedModule],
199        entry_points: &[EntryPoint],
200        runtime_entry_points: &[EntryPoint],
201        test_entry_points: &[EntryPoint],
202        files: &[DiscoveredFile],
203    ) -> Self {
204        let _span = tracing::info_span!("build_graph").entered();
205
206        let module_count = files.len();
207
208        let max_file_id = files
209            .iter()
210            .map(|f| f.id.0 as usize)
211            .max()
212            .map_or(0, |m| m + 1);
213        let total_capacity = max_file_id.max(module_count);
214
215        let path_to_id: FxHashMap<&Path, FileId> =
216            files.iter().map(|f| (f.path.as_path(), f.id)).collect();
217
218        let module_by_id: FxHashMap<FileId, &ResolvedModule> =
219            resolved_modules.iter().map(|m| (m.file_id, m)).collect();
220
221        let mut entry_point_ids = Self::resolve_entry_point_ids(entry_points, &path_to_id);
222        let runtime_entry_point_ids =
223            Self::resolve_entry_point_ids(runtime_entry_points, &path_to_id);
224        let test_entry_point_ids = Self::resolve_entry_point_ids(test_entry_points, &path_to_id);
225
226        for file in files {
227            if is_declaration_file_path(&file.path) {
228                entry_point_ids.insert(file.id);
229            }
230        }
231
232        let mut graph = Self::populate_edges(&build::PopulateEdgesInput {
233            files,
234            module_by_id: &module_by_id,
235            entry_point_ids: &entry_point_ids,
236            runtime_entry_point_ids: &runtime_entry_point_ids,
237            test_entry_point_ids: &test_entry_point_ids,
238            module_count,
239            total_capacity,
240        });
241
242        graph.populate_references(&module_by_id, &entry_point_ids);
243
244        namespace_aliases::propagate_cross_package_aliases(&mut graph, &module_by_id);
245
246        namespace_re_exports::propagate_namespace_re_exports(&mut graph, &module_by_id);
247
248        graph.mark_reachable(
249            &entry_point_ids,
250            &runtime_entry_point_ids,
251            &test_entry_point_ids,
252            total_capacity,
253        );
254
255        graph.re_export_cycles = graph.resolve_re_export_chains(&module_by_id);
256
257        graph
258    }
259
260    /// Total number of modules.
261    #[must_use]
262    pub const fn module_count(&self) -> usize {
263        self.modules.len()
264    }
265
266    /// Total number of edges.
267    #[must_use]
268    pub const fn edge_count(&self) -> usize {
269        self.edges.len()
270    }
271
272    /// Rebuild the `namespace_imported` bitset from the edge set.
273    ///
274    /// `namespace_imported` is `#[serde(skip)]`, so a graph loaded from the
275    /// persisted cache (`crate::cache`) arrives with an empty default bitset.
276    /// This restores it by replicating the EXACT insertion rule from
277    /// `build.rs`: a target `FileId` is namespace-imported iff some edge to it
278    /// carries an `ImportedName::Namespace` symbol. Both build-time insertion
279    /// sites (static / dynamic `import * as ns` in `collect_import_edge`, and
280    /// glob dynamic-import patterns in `collect_edges_for_module`) push a
281    /// `Namespace` symbol onto the target's edge, so iterating the persisted
282    /// edges and checking for a `Namespace` symbol reproduces the original
283    /// bitset bit-for-bit. The capacity matches `build.rs`'s
284    /// `max_file_id.max(module_count)`, which equals `modules.len()` under the
285    /// dense path-sorted FileId invariant.
286    pub(crate) fn reconstruct_namespace_imported(&mut self) {
287        let capacity = self
288            .edges
289            .iter()
290            .map(|edge| edge.target.0 as usize + 1)
291            .max()
292            .unwrap_or(0)
293            .max(self.modules.len());
294        let mut bitset = FixedBitSet::with_capacity(capacity);
295        for edge in &self.edges {
296            if edge
297                .symbols
298                .iter()
299                .any(|sym| matches!(sym.imported_name, ImportedName::Namespace))
300            {
301                let idx = edge.target.0 as usize;
302                if idx < capacity {
303                    bitset.insert(idx);
304                }
305            }
306        }
307        self.namespace_imported = bitset;
308    }
309
310    /// Check if any importer uses `import * as ns` for this module.
311    /// Uses precomputed bitset, O(1) lookup.
312    #[must_use]
313    pub fn has_namespace_import(&self, file_id: FileId) -> bool {
314        let idx = file_id.0 as usize;
315        if idx >= self.namespace_imported.len() {
316            return false;
317        }
318        self.namespace_imported.contains(idx)
319    }
320
321    /// Get the target `FileId`s of all outgoing edges for a module.
322    #[must_use]
323    pub fn edges_for(&self, file_id: FileId) -> Vec<FileId> {
324        let idx = file_id.0 as usize;
325        if idx >= self.modules.len() {
326            return Vec::new();
327        }
328        let range = &self.modules[idx].edge_range;
329        self.edges[range.clone()].iter().map(|e| e.target).collect()
330    }
331
332    /// Iterate the outgoing edges of `file_id` with full per-symbol data.
333    ///
334    /// `fallow trace` needs the raw `ImportedSymbol` set on each edge in
335    /// both directions, which the flattened summary structs cannot express.
336    /// Returns an empty iterator for out-of-range file ids.
337    pub fn outgoing_symbol_edges(
338        &self,
339        file_id: FileId,
340    ) -> impl Iterator<Item = (FileId, &[ImportedSymbol])> + '_ {
341        let idx = file_id.0 as usize;
342        let range = if idx < self.modules.len() {
343            self.modules[idx].edge_range.clone()
344        } else {
345            0..0
346        };
347        self.edges[range]
348            .iter()
349            .map(|edge| (edge.target, edge.symbols.as_slice()))
350    }
351
352    /// The importer `FileId`s that directly import `target` (reverse-dep view).
353    ///
354    /// Returns an empty slice when `target` is out of range.
355    #[must_use]
356    pub fn importers_of(&self, target: FileId) -> &[FileId] {
357        self.reverse_deps
358            .get(target.0 as usize)
359            .map_or(&[], Vec::as_slice)
360    }
361
362    /// Summarize files that directly import `target`.
363    ///
364    /// Uses existing reverse dependency and edge indexes. Returns an empty
365    /// list when the target is out of range or has no importers.
366    #[must_use]
367    pub fn direct_importer_summaries(&self, target: FileId) -> Vec<DirectImporterSummary> {
368        let Some(importers) = self.reverse_deps.get(target.0 as usize) else {
369            return Vec::new();
370        };
371
372        let mut summaries = Vec::new();
373        for &source in importers {
374            let idx = source.0 as usize;
375            let Some(source_node) = self.modules.get(idx) else {
376                continue;
377            };
378            let mut symbols = Vec::new();
379            for edge in &self.edges[source_node.edge_range.clone()] {
380                if edge.target != target {
381                    continue;
382                }
383                symbols.extend(edge.symbols.iter().map(|symbol| ImportedSymbolSummary {
384                    imported: imported_name_label(&symbol.imported_name),
385                    local: symbol.local_name.clone(),
386                    type_only: symbol.is_type_only,
387                }));
388            }
389            symbols.sort_by(|a, b| {
390                a.imported
391                    .cmp(&b.imported)
392                    .then_with(|| a.local.cmp(&b.local))
393                    .then_with(|| a.type_only.cmp(&b.type_only))
394            });
395            symbols.dedup();
396            summaries.push(DirectImporterSummary { source, symbols });
397        }
398        summaries.sort_by_key(|summary| summary.source.0);
399        summaries
400    }
401
402    /// Find the byte offset of the import statement from `source` to `target`.
403    ///
404    /// Mixed type/value imports to the same target are stored as one edge. Prefer
405    /// the first value-carrying import so runtime-cycle diagnostics and line
406    /// suppressions anchor on the import that actually participates in the cycle.
407    /// Returns `None` if no edge exists or the edge has no symbols.
408    #[must_use]
409    pub fn find_import_span_start(&self, source: FileId, target: FileId) -> Option<u32> {
410        let idx = source.0 as usize;
411        if idx >= self.modules.len() {
412            return None;
413        }
414        let range = &self.modules[idx].edge_range;
415        for edge in &self.edges[range.clone()] {
416            if edge.target == target {
417                return edge
418                    .symbols
419                    .iter()
420                    .find(|s| !s.is_type_only)
421                    .or_else(|| edge.symbols.first())
422                    .map(|s| s.import_span.start);
423            }
424        }
425        None
426    }
427
428    /// Iterate outgoing edges with the data the boundary detector needs in a
429    /// single pass: target file id, whether every symbol on the edge is
430    /// type-only (matches the predicate used by cycle detection), and the
431    /// span start of the first value-carrying symbol (or the first symbol
432    /// when every symbol is type-only).
433    ///
434    /// When `featureB` has both `import type { Foo } from './x'` and
435    /// `import { bar } from './x'`, fallow groups them into ONE edge with the
436    /// type-only symbol first and the value symbol second. Consumers need the
437    /// value span so findings anchor on the runtime import line; otherwise a
438    /// `// fallow-ignore-next-line` above the type-only line would silently
439    /// suppress the real violation.
440    ///
441    /// Returns an empty iterator for out-of-range file ids.
442    pub fn outgoing_edge_summaries(
443        &self,
444        file_id: FileId,
445    ) -> impl Iterator<Item = (FileId, bool, Option<u32>)> + '_ {
446        let idx = file_id.0 as usize;
447        let range = if idx < self.modules.len() {
448            self.modules[idx].edge_range.clone()
449        } else {
450            0..0
451        };
452        self.edges[range].iter().map(|edge| {
453            let all_type_only =
454                !edge.symbols.is_empty() && edge.symbols.iter().all(|s| s.is_type_only);
455            let span = edge
456                .symbols
457                .iter()
458                .find(|s| !s.is_type_only)
459                .or_else(|| edge.symbols.first())
460                .map(|s| s.import_span.start);
461            (edge.target, all_type_only, span)
462        })
463    }
464
465    /// Like [`Self::outgoing_edge_summaries`] but additionally reports, as a
466    /// fourth boolean, whether EVERY non-type-only symbol on the edge has an
467    /// `import_span` start in `excluded_span_starts` (`all_client_only`). The
468    /// security `client-server-leak` BFS passes the `next/dynamic ssr:false`
469    /// dynamic-import span starts so it can skip an edge reached ONLY through the
470    /// client-only escape hatch. An edge with no non-type-only symbols, or with at
471    /// least one non-type-only symbol whose span is not excluded, reports `false`
472    /// (so a target also reached via a real static import stays in the cone).
473    ///
474    /// Returns an empty iterator for out-of-range file ids.
475    pub fn outgoing_edge_summaries_with_exclusions<'a>(
476        &'a self,
477        file_id: FileId,
478        excluded_span_starts: &'a FxHashSet<u32>,
479    ) -> impl Iterator<Item = (FileId, bool, Option<u32>, bool)> + 'a {
480        let idx = file_id.0 as usize;
481        let range = if idx < self.modules.len() {
482            self.modules[idx].edge_range.clone()
483        } else {
484            0..0
485        };
486        self.edges[range].iter().map(move |edge| {
487            let all_type_only =
488                !edge.symbols.is_empty() && edge.symbols.iter().all(|s| s.is_type_only);
489            let span = edge
490                .symbols
491                .iter()
492                .find(|s| !s.is_type_only)
493                .or_else(|| edge.symbols.first())
494                .map(|s| s.import_span.start);
495            // `all_client_only`: there is at least one non-type-only symbol and
496            // every such symbol's import span is in the excluded set. A
497            // non-excluded value symbol keeps the edge live.
498            let mut value_symbols = edge.symbols.iter().filter(|s| !s.is_type_only).peekable();
499            let all_client_only = value_symbols.peek().is_some()
500                && value_symbols.all(|s| excluded_span_starts.contains(&s.import_span.start));
501            (edge.target, all_type_only, span, all_client_only)
502        })
503    }
504}
505
506fn imported_name_label(name: &ImportedName) -> String {
507    match name {
508        ImportedName::Named(name) => name.clone(),
509        ImportedName::Default => "default".to_string(),
510        ImportedName::Namespace => "*".to_string(),
511        ImportedName::SideEffect => "side-effect".to_string(),
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
519    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
520    use fallow_types::extract::{ExportName, ImportInfo, ImportedName, VisibilityTag};
521    use std::path::PathBuf;
522
523    fn build_simple_graph() -> ModuleGraph {
524        let files = vec![
525            DiscoveredFile {
526                id: FileId(0),
527                path: PathBuf::from("/project/src/entry.ts"),
528                size_bytes: 100,
529            },
530            DiscoveredFile {
531                id: FileId(1),
532                path: PathBuf::from("/project/src/utils.ts"),
533                size_bytes: 50,
534            },
535        ];
536
537        let entry_points = vec![EntryPoint {
538            path: PathBuf::from("/project/src/entry.ts"),
539            source: EntryPointSource::PackageJsonMain,
540        }];
541
542        let resolved_modules = vec![
543            ResolvedModule {
544                file_id: FileId(0),
545                path: PathBuf::from("/project/src/entry.ts"),
546                resolved_imports: vec![ResolvedImport {
547                    info: ImportInfo {
548                        source: "./utils".to_string(),
549                        imported_name: ImportedName::Named("foo".to_string()),
550                        local_name: "foo".to_string(),
551                        is_type_only: false,
552                        from_style: false,
553                        span: oxc_span::Span::new(0, 10),
554                        source_span: oxc_span::Span::default(),
555                    },
556                    target: ResolveResult::InternalModule(FileId(1)),
557                }],
558                ..Default::default()
559            },
560            ResolvedModule {
561                file_id: FileId(1),
562                path: PathBuf::from("/project/src/utils.ts"),
563                exports: vec![
564                    fallow_types::extract::ExportInfo {
565                        name: ExportName::Named("foo".to_string()),
566                        local_name: Some("foo".to_string()),
567                        is_type_only: false,
568                        visibility: VisibilityTag::None,
569                        expected_unused_reason: None,
570                        span: oxc_span::Span::new(0, 20),
571                        members: vec![],
572                        is_side_effect_used: false,
573                        super_class: None,
574                    },
575                    fallow_types::extract::ExportInfo {
576                        name: ExportName::Named("bar".to_string()),
577                        local_name: Some("bar".to_string()),
578                        is_type_only: false,
579                        visibility: VisibilityTag::None,
580                        expected_unused_reason: None,
581                        span: oxc_span::Span::new(25, 45),
582                        members: vec![],
583                        is_side_effect_used: false,
584                        super_class: None,
585                    },
586                ],
587                ..Default::default()
588            },
589        ];
590
591        ModuleGraph::build(&resolved_modules, &entry_points, &files)
592    }
593
594    #[test]
595    fn graph_module_count() {
596        let graph = build_simple_graph();
597        assert_eq!(graph.module_count(), 2);
598    }
599
600    #[test]
601    fn graph_edge_count() {
602        let graph = build_simple_graph();
603        assert_eq!(graph.edge_count(), 1);
604    }
605
606    #[test]
607    fn graph_entry_point_is_reachable() {
608        let graph = build_simple_graph();
609        assert!(graph.modules[0].is_entry_point());
610        assert!(graph.modules[0].is_reachable());
611    }
612
613    #[test]
614    fn graph_imported_module_is_reachable() {
615        let graph = build_simple_graph();
616        assert!(!graph.modules[1].is_entry_point());
617        assert!(graph.modules[1].is_reachable());
618    }
619
620    #[test]
621    #[expect(
622        clippy::too_many_lines,
623        reason = "this test fixture exercises four reachability roles end-to-end; splitting it \
624                  would obscure the cross-role assertions"
625    )]
626    fn graph_distinguishes_runtime_test_and_support_reachability() {
627        let files = vec![
628            DiscoveredFile {
629                id: FileId(0),
630                path: PathBuf::from("/project/src/main.ts"),
631                size_bytes: 100,
632            },
633            DiscoveredFile {
634                id: FileId(1),
635                path: PathBuf::from("/project/src/runtime-only.ts"),
636                size_bytes: 50,
637            },
638            DiscoveredFile {
639                id: FileId(2),
640                path: PathBuf::from("/project/tests/app.test.ts"),
641                size_bytes: 50,
642            },
643            DiscoveredFile {
644                id: FileId(3),
645                path: PathBuf::from("/project/tests/setup.ts"),
646                size_bytes: 50,
647            },
648            DiscoveredFile {
649                id: FileId(4),
650                path: PathBuf::from("/project/src/covered.ts"),
651                size_bytes: 50,
652            },
653        ];
654
655        let all_entry_points = vec![
656            EntryPoint {
657                path: PathBuf::from("/project/src/main.ts"),
658                source: EntryPointSource::PackageJsonMain,
659            },
660            EntryPoint {
661                path: PathBuf::from("/project/tests/app.test.ts"),
662                source: EntryPointSource::TestFile,
663            },
664            EntryPoint {
665                path: PathBuf::from("/project/tests/setup.ts"),
666                source: EntryPointSource::Plugin {
667                    name: "vitest".to_string(),
668                },
669            },
670        ];
671        let runtime_entry_points = vec![EntryPoint {
672            path: PathBuf::from("/project/src/main.ts"),
673            source: EntryPointSource::PackageJsonMain,
674        }];
675        let test_entry_points = vec![EntryPoint {
676            path: PathBuf::from("/project/tests/app.test.ts"),
677            source: EntryPointSource::TestFile,
678        }];
679
680        let resolved_modules = vec![
681            ResolvedModule {
682                file_id: FileId(0),
683                path: PathBuf::from("/project/src/main.ts"),
684                resolved_imports: vec![ResolvedImport {
685                    info: ImportInfo {
686                        source: "./runtime-only".to_string(),
687                        imported_name: ImportedName::Named("runtimeOnly".to_string()),
688                        local_name: "runtimeOnly".to_string(),
689                        is_type_only: false,
690                        from_style: false,
691                        span: oxc_span::Span::new(0, 10),
692                        source_span: oxc_span::Span::default(),
693                    },
694                    target: ResolveResult::InternalModule(FileId(1)),
695                }],
696                ..Default::default()
697            },
698            ResolvedModule {
699                file_id: FileId(1),
700                path: PathBuf::from("/project/src/runtime-only.ts"),
701                exports: vec![fallow_types::extract::ExportInfo {
702                    name: ExportName::Named("runtimeOnly".to_string()),
703                    local_name: Some("runtimeOnly".to_string()),
704                    is_type_only: false,
705                    visibility: VisibilityTag::None,
706                    expected_unused_reason: None,
707                    span: oxc_span::Span::new(0, 20),
708                    members: vec![],
709                    is_side_effect_used: false,
710                    super_class: None,
711                }],
712                ..Default::default()
713            },
714            ResolvedModule {
715                file_id: FileId(2),
716                path: PathBuf::from("/project/tests/app.test.ts"),
717                resolved_imports: vec![ResolvedImport {
718                    info: ImportInfo {
719                        source: "../src/covered".to_string(),
720                        imported_name: ImportedName::Named("covered".to_string()),
721                        local_name: "covered".to_string(),
722                        is_type_only: false,
723                        from_style: false,
724                        span: oxc_span::Span::new(0, 10),
725                        source_span: oxc_span::Span::default(),
726                    },
727                    target: ResolveResult::InternalModule(FileId(4)),
728                }],
729                ..Default::default()
730            },
731            ResolvedModule {
732                file_id: FileId(3),
733                path: PathBuf::from("/project/tests/setup.ts"),
734                resolved_imports: vec![ResolvedImport {
735                    info: ImportInfo {
736                        source: "../src/runtime-only".to_string(),
737                        imported_name: ImportedName::Named("runtimeOnly".to_string()),
738                        local_name: "runtimeOnly".to_string(),
739                        is_type_only: false,
740                        from_style: false,
741                        span: oxc_span::Span::new(0, 10),
742                        source_span: oxc_span::Span::default(),
743                    },
744                    target: ResolveResult::InternalModule(FileId(1)),
745                }],
746                ..Default::default()
747            },
748            ResolvedModule {
749                file_id: FileId(4),
750                path: PathBuf::from("/project/src/covered.ts"),
751                exports: vec![fallow_types::extract::ExportInfo {
752                    name: ExportName::Named("covered".to_string()),
753                    local_name: Some("covered".to_string()),
754                    is_type_only: false,
755                    visibility: VisibilityTag::None,
756                    expected_unused_reason: None,
757                    span: oxc_span::Span::new(0, 20),
758                    members: vec![],
759                    is_side_effect_used: false,
760                    super_class: None,
761                }],
762                ..Default::default()
763            },
764        ];
765
766        let graph = ModuleGraph::build_with_reachability_roots(
767            &resolved_modules,
768            &all_entry_points,
769            &runtime_entry_points,
770            &test_entry_points,
771            &files,
772        );
773
774        assert!(graph.modules[1].is_reachable());
775        assert!(graph.modules[1].is_runtime_reachable());
776        assert!(
777            !graph.modules[1].is_test_reachable(),
778            "support roots should not make runtime-only modules test reachable"
779        );
780
781        assert!(graph.modules[4].is_reachable());
782        assert!(graph.modules[4].is_test_reachable());
783        assert!(
784            !graph.modules[4].is_runtime_reachable(),
785            "test-only reachability should stay separate from runtime roots"
786        );
787    }
788
789    #[test]
790    fn graph_export_has_reference() {
791        let graph = build_simple_graph();
792        let utils = &graph.modules[1];
793        let foo_export = utils
794            .exports
795            .iter()
796            .find(|e| e.name.to_string() == "foo")
797            .unwrap();
798        assert!(
799            !foo_export.references.is_empty(),
800            "foo should have references"
801        );
802    }
803
804    #[test]
805    fn graph_unused_export_no_reference() {
806        let graph = build_simple_graph();
807        let utils = &graph.modules[1];
808        let bar_export = utils
809            .exports
810            .iter()
811            .find(|e| e.name.to_string() == "bar")
812            .unwrap();
813        assert!(
814            bar_export.references.is_empty(),
815            "bar should have no references"
816        );
817    }
818
819    #[test]
820    fn graph_no_namespace_import() {
821        let graph = build_simple_graph();
822        assert!(!graph.has_namespace_import(FileId(0)));
823        assert!(!graph.has_namespace_import(FileId(1)));
824    }
825
826    #[test]
827    fn graph_has_namespace_import() {
828        let files = vec![
829            DiscoveredFile {
830                id: FileId(0),
831                path: PathBuf::from("/project/entry.ts"),
832                size_bytes: 100,
833            },
834            DiscoveredFile {
835                id: FileId(1),
836                path: PathBuf::from("/project/utils.ts"),
837                size_bytes: 50,
838            },
839        ];
840
841        let entry_points = vec![EntryPoint {
842            path: PathBuf::from("/project/entry.ts"),
843            source: EntryPointSource::PackageJsonMain,
844        }];
845
846        let resolved_modules = vec![
847            ResolvedModule {
848                file_id: FileId(0),
849                path: PathBuf::from("/project/entry.ts"),
850                resolved_imports: vec![ResolvedImport {
851                    info: ImportInfo {
852                        source: "./utils".to_string(),
853                        imported_name: ImportedName::Namespace,
854                        local_name: "utils".to_string(),
855                        is_type_only: false,
856                        from_style: false,
857                        span: oxc_span::Span::new(0, 10),
858                        source_span: oxc_span::Span::default(),
859                    },
860                    target: ResolveResult::InternalModule(FileId(1)),
861                }],
862                ..Default::default()
863            },
864            ResolvedModule {
865                file_id: FileId(1),
866                path: PathBuf::from("/project/utils.ts"),
867                exports: vec![fallow_types::extract::ExportInfo {
868                    name: ExportName::Named("foo".to_string()),
869                    local_name: Some("foo".to_string()),
870                    is_type_only: false,
871                    visibility: VisibilityTag::None,
872                    expected_unused_reason: None,
873                    span: oxc_span::Span::new(0, 20),
874                    members: vec![],
875                    is_side_effect_used: false,
876                    super_class: None,
877                }],
878                ..Default::default()
879            },
880        ];
881
882        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
883        assert!(
884            graph.has_namespace_import(FileId(1)),
885            "utils should have namespace import"
886        );
887    }
888
889    #[test]
890    fn graph_has_namespace_import_out_of_bounds() {
891        let graph = build_simple_graph();
892        assert!(!graph.has_namespace_import(FileId(999)));
893    }
894
895    /// The persisted graph cache skips `namespace_imported` and rebuilds it from
896    /// the edge set on load. This asserts the reconstruction reproduces the
897    /// fresh-built bitset BIT-FOR-BIT on a graph that exercises `import * as ns`,
898    /// matching what `build.rs` records at build time.
899    #[test]
900    fn reconstruct_namespace_imported_matches_fresh_build() {
901        let files = vec![
902            DiscoveredFile {
903                id: FileId(0),
904                path: PathBuf::from("/project/entry.ts"),
905                size_bytes: 100,
906            },
907            DiscoveredFile {
908                id: FileId(1),
909                path: PathBuf::from("/project/utils.ts"),
910                size_bytes: 50,
911            },
912            DiscoveredFile {
913                id: FileId(2),
914                path: PathBuf::from("/project/named-only.ts"),
915                size_bytes: 50,
916            },
917        ];
918        let entry_points = vec![EntryPoint {
919            path: PathBuf::from("/project/entry.ts"),
920            source: EntryPointSource::PackageJsonMain,
921        }];
922        let resolved_modules = vec![
923            ResolvedModule {
924                file_id: FileId(0),
925                path: PathBuf::from("/project/entry.ts"),
926                resolved_imports: vec![
927                    ResolvedImport {
928                        info: ImportInfo {
929                            source: "./utils".to_string(),
930                            imported_name: ImportedName::Namespace,
931                            local_name: "utils".to_string(),
932                            is_type_only: false,
933                            from_style: false,
934                            span: oxc_span::Span::new(0, 10),
935                            source_span: oxc_span::Span::default(),
936                        },
937                        target: ResolveResult::InternalModule(FileId(1)),
938                    },
939                    ResolvedImport {
940                        info: ImportInfo {
941                            source: "./named-only".to_string(),
942                            imported_name: ImportedName::Named("foo".to_string()),
943                            local_name: "foo".to_string(),
944                            is_type_only: false,
945                            from_style: false,
946                            span: oxc_span::Span::new(11, 20),
947                            source_span: oxc_span::Span::default(),
948                        },
949                        target: ResolveResult::InternalModule(FileId(2)),
950                    },
951                ],
952                ..Default::default()
953            },
954            ResolvedModule {
955                file_id: FileId(1),
956                path: PathBuf::from("/project/utils.ts"),
957                ..Default::default()
958            },
959            ResolvedModule {
960                file_id: FileId(2),
961                path: PathBuf::from("/project/named-only.ts"),
962                exports: vec![fallow_types::extract::ExportInfo {
963                    name: ExportName::Named("foo".to_string()),
964                    local_name: Some("foo".to_string()),
965                    is_type_only: false,
966                    visibility: VisibilityTag::None,
967                    expected_unused_reason: None,
968                    span: oxc_span::Span::new(0, 20),
969                    members: vec![],
970                    is_side_effect_used: false,
971                    super_class: None,
972                }],
973                ..Default::default()
974            },
975        ];
976
977        let mut graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
978        let fresh = graph.namespace_imported.clone();
979
980        // Sanity: the namespace target is set, the named-only target is not.
981        assert!(graph.has_namespace_import(FileId(1)));
982        assert!(!graph.has_namespace_import(FileId(2)));
983
984        // Simulate the cache load: the bitset arrives empty (serde-skipped), then
985        // the loader reconstructs it from the persisted edges.
986        graph.namespace_imported = FixedBitSet::default();
987        graph.reconstruct_namespace_imported();
988
989        assert_eq!(
990            graph.namespace_imported, fresh,
991            "reconstructed namespace_imported must equal the fresh-built bitset"
992        );
993        assert!(graph.has_namespace_import(FileId(1)));
994        assert!(!graph.has_namespace_import(FileId(2)));
995    }
996
997    #[test]
998    fn graph_unreachable_module() {
999        let files = vec![
1000            DiscoveredFile {
1001                id: FileId(0),
1002                path: PathBuf::from("/project/entry.ts"),
1003                size_bytes: 100,
1004            },
1005            DiscoveredFile {
1006                id: FileId(1),
1007                path: PathBuf::from("/project/utils.ts"),
1008                size_bytes: 50,
1009            },
1010            DiscoveredFile {
1011                id: FileId(2),
1012                path: PathBuf::from("/project/orphan.ts"),
1013                size_bytes: 30,
1014            },
1015        ];
1016
1017        let entry_points = vec![EntryPoint {
1018            path: PathBuf::from("/project/entry.ts"),
1019            source: EntryPointSource::PackageJsonMain,
1020        }];
1021
1022        let resolved_modules = vec![
1023            ResolvedModule {
1024                file_id: FileId(0),
1025                path: PathBuf::from("/project/entry.ts"),
1026                resolved_imports: vec![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: false,
1032                        from_style: false,
1033                        span: oxc_span::Span::new(0, 10),
1034                        source_span: oxc_span::Span::default(),
1035                    },
1036                    target: ResolveResult::InternalModule(FileId(1)),
1037                }],
1038                ..Default::default()
1039            },
1040            ResolvedModule {
1041                file_id: FileId(1),
1042                path: PathBuf::from("/project/utils.ts"),
1043                exports: vec![fallow_types::extract::ExportInfo {
1044                    name: ExportName::Named("foo".to_string()),
1045                    local_name: Some("foo".to_string()),
1046                    is_type_only: false,
1047                    visibility: VisibilityTag::None,
1048                    expected_unused_reason: None,
1049                    span: oxc_span::Span::new(0, 20),
1050                    members: vec![],
1051                    is_side_effect_used: false,
1052                    super_class: None,
1053                }],
1054                ..Default::default()
1055            },
1056            ResolvedModule {
1057                file_id: FileId(2),
1058                path: PathBuf::from("/project/orphan.ts"),
1059                exports: vec![fallow_types::extract::ExportInfo {
1060                    name: ExportName::Named("orphan".to_string()),
1061                    local_name: Some("orphan".to_string()),
1062                    is_type_only: false,
1063                    visibility: VisibilityTag::None,
1064                    expected_unused_reason: None,
1065                    span: oxc_span::Span::new(0, 20),
1066                    members: vec![],
1067                    is_side_effect_used: false,
1068                    super_class: None,
1069                }],
1070                ..Default::default()
1071            },
1072        ];
1073
1074        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1075
1076        assert!(graph.modules[0].is_reachable(), "entry should be reachable");
1077        assert!(graph.modules[1].is_reachable(), "utils should be reachable");
1078        assert!(
1079            !graph.modules[2].is_reachable(),
1080            "orphan should NOT be reachable"
1081        );
1082    }
1083
1084    #[test]
1085    fn graph_package_usage_tracked() {
1086        let files = vec![DiscoveredFile {
1087            id: FileId(0),
1088            path: PathBuf::from("/project/entry.ts"),
1089            size_bytes: 100,
1090        }];
1091
1092        let entry_points = vec![EntryPoint {
1093            path: PathBuf::from("/project/entry.ts"),
1094            source: EntryPointSource::PackageJsonMain,
1095        }];
1096
1097        let resolved_modules = vec![ResolvedModule {
1098            file_id: FileId(0),
1099            path: PathBuf::from("/project/entry.ts"),
1100            exports: vec![],
1101            re_exports: vec![],
1102            resolved_imports: vec![
1103                ResolvedImport {
1104                    info: ImportInfo {
1105                        source: "react".to_string(),
1106                        imported_name: ImportedName::Default,
1107                        local_name: "React".to_string(),
1108                        is_type_only: false,
1109                        from_style: false,
1110                        span: oxc_span::Span::new(0, 10),
1111                        source_span: oxc_span::Span::default(),
1112                    },
1113                    target: ResolveResult::NpmPackage("react".to_string()),
1114                },
1115                ResolvedImport {
1116                    info: ImportInfo {
1117                        source: "lodash".to_string(),
1118                        imported_name: ImportedName::Named("merge".to_string()),
1119                        local_name: "merge".to_string(),
1120                        is_type_only: false,
1121                        from_style: false,
1122                        span: oxc_span::Span::new(15, 30),
1123                        source_span: oxc_span::Span::default(),
1124                    },
1125                    target: ResolveResult::NpmPackage("lodash".to_string()),
1126                },
1127            ],
1128            ..Default::default()
1129        }];
1130
1131        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1132        assert!(graph.package_usage.contains_key("react"));
1133        assert!(graph.package_usage.contains_key("lodash"));
1134        assert!(!graph.package_usage.contains_key("express"));
1135    }
1136
1137    #[test]
1138    fn graph_empty() {
1139        let graph = ModuleGraph::build(&[], &[], &[]);
1140        assert_eq!(graph.module_count(), 0);
1141        assert_eq!(graph.edge_count(), 0);
1142    }
1143
1144    /// The persisted graph cache postcard-encodes the whole `ModuleGraph` and
1145    /// decodes it on a warm run. This proves the serde round-trip is lossless
1146    /// for the structural surface analysis reads: module / edge / export /
1147    /// reference counts and the `namespace_imported` bitset (reconstructed on
1148    /// load) all survive.
1149    #[test]
1150    fn graph_postcard_round_trip_is_lossless() {
1151        let graph = build_simple_graph();
1152
1153        let encoded = postcard::to_allocvec(&graph).expect("encode graph");
1154        let mut decoded: ModuleGraph = postcard::from_bytes(&encoded).expect("decode graph");
1155        // The store does this on load; do it here so the bitset is restored.
1156        decoded.reconstruct_namespace_imported();
1157
1158        assert_eq!(decoded.module_count(), graph.module_count());
1159        assert_eq!(decoded.edge_count(), graph.edge_count());
1160        assert_eq!(decoded.namespace_imported, graph.namespace_imported);
1161
1162        // Export + reference + member surface survives byte-for-byte.
1163        let utils = &decoded.modules[1];
1164        let foo = utils
1165            .exports
1166            .iter()
1167            .find(|e| e.name.to_string() == "foo")
1168            .expect("foo export survives round-trip");
1169        assert!(!foo.references.is_empty());
1170        let bar = utils
1171            .exports
1172            .iter()
1173            .find(|e| e.name.to_string() == "bar")
1174            .expect("bar export survives round-trip");
1175        assert!(bar.references.is_empty());
1176
1177        // Reachability flags and entry-point sets survive.
1178        assert!(decoded.modules[0].is_entry_point());
1179        assert!(decoded.modules[0].is_reachable());
1180        assert!(decoded.modules[1].is_reachable());
1181        assert_eq!(decoded.entry_points, graph.entry_points);
1182    }
1183
1184    #[test]
1185    fn graph_cjs_exports_tracked() {
1186        let files = vec![DiscoveredFile {
1187            id: FileId(0),
1188            path: PathBuf::from("/project/entry.ts"),
1189            size_bytes: 100,
1190        }];
1191
1192        let entry_points = vec![EntryPoint {
1193            path: PathBuf::from("/project/entry.ts"),
1194            source: EntryPointSource::PackageJsonMain,
1195        }];
1196
1197        let resolved_modules = vec![ResolvedModule {
1198            file_id: FileId(0),
1199            path: PathBuf::from("/project/entry.ts"),
1200            has_cjs_exports: true,
1201            has_angular_component_template_url: false,
1202            ..Default::default()
1203        }];
1204
1205        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1206        assert!(graph.modules[0].has_cjs_exports());
1207    }
1208
1209    #[test]
1210    fn graph_edges_for_returns_targets() {
1211        let graph = build_simple_graph();
1212        let targets = graph.edges_for(FileId(0));
1213        assert_eq!(targets, vec![FileId(1)]);
1214    }
1215
1216    #[test]
1217    fn graph_edges_for_no_imports() {
1218        let graph = build_simple_graph();
1219        let targets = graph.edges_for(FileId(1));
1220        assert!(targets.is_empty());
1221    }
1222
1223    #[test]
1224    fn graph_edges_for_out_of_bounds() {
1225        let graph = build_simple_graph();
1226        let targets = graph.edges_for(FileId(999));
1227        assert!(targets.is_empty());
1228    }
1229
1230    #[test]
1231    fn graph_direct_importer_summaries_include_symbols() {
1232        let graph = build_simple_graph();
1233        let summaries = graph.direct_importer_summaries(FileId(1));
1234
1235        assert_eq!(
1236            summaries,
1237            vec![DirectImporterSummary {
1238                source: FileId(0),
1239                symbols: vec![ImportedSymbolSummary {
1240                    imported: "foo".to_string(),
1241                    local: "foo".to_string(),
1242                    type_only: false,
1243                }],
1244            }]
1245        );
1246    }
1247
1248    #[test]
1249    fn graph_find_import_span_start_found() {
1250        let graph = build_simple_graph();
1251        let span_start = graph.find_import_span_start(FileId(0), FileId(1));
1252        assert!(span_start.is_some());
1253        assert_eq!(span_start.unwrap(), 0);
1254    }
1255
1256    #[test]
1257    fn graph_find_import_span_start_prefers_value_import_on_mixed_edge() {
1258        let files = vec![
1259            DiscoveredFile {
1260                id: FileId(0),
1261                path: PathBuf::from("/project/entry.ts"),
1262                size_bytes: 100,
1263            },
1264            DiscoveredFile {
1265                id: FileId(1),
1266                path: PathBuf::from("/project/utils.ts"),
1267                size_bytes: 50,
1268            },
1269        ];
1270        let entry_points = vec![EntryPoint {
1271            path: PathBuf::from("/project/entry.ts"),
1272            source: EntryPointSource::PackageJsonMain,
1273        }];
1274        let resolved_modules = vec![
1275            ResolvedModule {
1276                file_id: FileId(0),
1277                path: PathBuf::from("/project/entry.ts"),
1278                resolved_imports: vec![
1279                    ResolvedImport {
1280                        info: ImportInfo {
1281                            source: "./utils".to_string(),
1282                            imported_name: ImportedName::Named("Foo".to_string()),
1283                            local_name: "Foo".to_string(),
1284                            is_type_only: true,
1285                            from_style: false,
1286                            span: oxc_span::Span::new(10, 20),
1287                            source_span: oxc_span::Span::default(),
1288                        },
1289                        target: ResolveResult::InternalModule(FileId(1)),
1290                    },
1291                    ResolvedImport {
1292                        info: ImportInfo {
1293                            source: "./utils".to_string(),
1294                            imported_name: ImportedName::Named("foo".to_string()),
1295                            local_name: "foo".to_string(),
1296                            is_type_only: false,
1297                            from_style: false,
1298                            span: oxc_span::Span::new(50, 60),
1299                            source_span: oxc_span::Span::default(),
1300                        },
1301                        target: ResolveResult::InternalModule(FileId(1)),
1302                    },
1303                ],
1304                ..Default::default()
1305            },
1306            ResolvedModule {
1307                file_id: FileId(1),
1308                path: PathBuf::from("/project/utils.ts"),
1309                ..Default::default()
1310            },
1311        ];
1312
1313        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1314        assert_eq!(graph.find_import_span_start(FileId(0), FileId(1)), Some(50));
1315    }
1316
1317    #[test]
1318    fn graph_find_import_span_start_wrong_target() {
1319        let graph = build_simple_graph();
1320        let span_start = graph.find_import_span_start(FileId(0), FileId(0));
1321        assert!(span_start.is_none());
1322    }
1323
1324    #[test]
1325    fn graph_find_import_span_start_source_out_of_bounds() {
1326        let graph = build_simple_graph();
1327        let span_start = graph.find_import_span_start(FileId(999), FileId(1));
1328        assert!(span_start.is_none());
1329    }
1330
1331    #[test]
1332    fn graph_find_import_span_start_no_edges() {
1333        let graph = build_simple_graph();
1334        let span_start = graph.find_import_span_start(FileId(1), FileId(0));
1335        assert!(span_start.is_none());
1336    }
1337
1338    #[test]
1339    fn graph_reverse_deps_populated() {
1340        let graph = build_simple_graph();
1341        assert!(graph.reverse_deps[1].contains(&FileId(0)));
1342        assert!(graph.reverse_deps[0].is_empty());
1343    }
1344
1345    #[test]
1346    fn graph_type_only_package_usage_tracked() {
1347        let files = vec![DiscoveredFile {
1348            id: FileId(0),
1349            path: PathBuf::from("/project/entry.ts"),
1350            size_bytes: 100,
1351        }];
1352        let entry_points = vec![EntryPoint {
1353            path: PathBuf::from("/project/entry.ts"),
1354            source: EntryPointSource::PackageJsonMain,
1355        }];
1356        let resolved_modules = vec![ResolvedModule {
1357            file_id: FileId(0),
1358            path: PathBuf::from("/project/entry.ts"),
1359            resolved_imports: vec![
1360                ResolvedImport {
1361                    info: ImportInfo {
1362                        source: "react".to_string(),
1363                        imported_name: ImportedName::Named("FC".to_string()),
1364                        local_name: "FC".to_string(),
1365                        is_type_only: true,
1366                        from_style: false,
1367                        span: oxc_span::Span::new(0, 10),
1368                        source_span: oxc_span::Span::default(),
1369                    },
1370                    target: ResolveResult::NpmPackage("react".to_string()),
1371                },
1372                ResolvedImport {
1373                    info: ImportInfo {
1374                        source: "react".to_string(),
1375                        imported_name: ImportedName::Named("useState".to_string()),
1376                        local_name: "useState".to_string(),
1377                        is_type_only: false,
1378                        from_style: false,
1379                        span: oxc_span::Span::new(15, 30),
1380                        source_span: oxc_span::Span::default(),
1381                    },
1382                    target: ResolveResult::NpmPackage("react".to_string()),
1383                },
1384            ],
1385            ..Default::default()
1386        }];
1387
1388        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1389        assert!(graph.package_usage.contains_key("react"));
1390        assert!(graph.type_only_package_usage.contains_key("react"));
1391    }
1392
1393    #[test]
1394    fn graph_default_import_reference() {
1395        let files = vec![
1396            DiscoveredFile {
1397                id: FileId(0),
1398                path: PathBuf::from("/project/entry.ts"),
1399                size_bytes: 100,
1400            },
1401            DiscoveredFile {
1402                id: FileId(1),
1403                path: PathBuf::from("/project/utils.ts"),
1404                size_bytes: 50,
1405            },
1406        ];
1407        let entry_points = vec![EntryPoint {
1408            path: PathBuf::from("/project/entry.ts"),
1409            source: EntryPointSource::PackageJsonMain,
1410        }];
1411        let resolved_modules = vec![
1412            ResolvedModule {
1413                file_id: FileId(0),
1414                path: PathBuf::from("/project/entry.ts"),
1415                resolved_imports: vec![ResolvedImport {
1416                    info: ImportInfo {
1417                        source: "./utils".to_string(),
1418                        imported_name: ImportedName::Default,
1419                        local_name: "Utils".to_string(),
1420                        is_type_only: false,
1421                        from_style: false,
1422                        span: oxc_span::Span::new(0, 10),
1423                        source_span: oxc_span::Span::default(),
1424                    },
1425                    target: ResolveResult::InternalModule(FileId(1)),
1426                }],
1427                ..Default::default()
1428            },
1429            ResolvedModule {
1430                file_id: FileId(1),
1431                path: PathBuf::from("/project/utils.ts"),
1432                exports: vec![fallow_types::extract::ExportInfo {
1433                    name: ExportName::Default,
1434                    local_name: None,
1435                    is_type_only: false,
1436                    visibility: VisibilityTag::None,
1437                    expected_unused_reason: None,
1438                    span: oxc_span::Span::new(0, 20),
1439                    members: vec![],
1440                    is_side_effect_used: false,
1441                    super_class: None,
1442                }],
1443                ..Default::default()
1444            },
1445        ];
1446
1447        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1448        let utils = &graph.modules[1];
1449        let default_export = utils
1450            .exports
1451            .iter()
1452            .find(|e| matches!(e.name, ExportName::Default))
1453            .unwrap();
1454        assert!(!default_export.references.is_empty());
1455        assert_eq!(
1456            default_export.references[0].kind,
1457            ReferenceKind::DefaultImport
1458        );
1459    }
1460
1461    #[test]
1462    fn graph_side_effect_import_no_export_reference() {
1463        let files = vec![
1464            DiscoveredFile {
1465                id: FileId(0),
1466                path: PathBuf::from("/project/entry.ts"),
1467                size_bytes: 100,
1468            },
1469            DiscoveredFile {
1470                id: FileId(1),
1471                path: PathBuf::from("/project/styles.ts"),
1472                size_bytes: 50,
1473            },
1474        ];
1475        let entry_points = vec![EntryPoint {
1476            path: PathBuf::from("/project/entry.ts"),
1477            source: EntryPointSource::PackageJsonMain,
1478        }];
1479        let resolved_modules = vec![
1480            ResolvedModule {
1481                file_id: FileId(0),
1482                path: PathBuf::from("/project/entry.ts"),
1483                resolved_imports: vec![ResolvedImport {
1484                    info: ImportInfo {
1485                        source: "./styles".to_string(),
1486                        imported_name: ImportedName::SideEffect,
1487                        local_name: String::new(),
1488                        is_type_only: false,
1489                        from_style: false,
1490                        span: oxc_span::Span::new(0, 10),
1491                        source_span: oxc_span::Span::default(),
1492                    },
1493                    target: ResolveResult::InternalModule(FileId(1)),
1494                }],
1495                ..Default::default()
1496            },
1497            ResolvedModule {
1498                file_id: FileId(1),
1499                path: PathBuf::from("/project/styles.ts"),
1500                exports: vec![fallow_types::extract::ExportInfo {
1501                    name: ExportName::Named("primaryColor".to_string()),
1502                    local_name: Some("primaryColor".to_string()),
1503                    is_type_only: false,
1504                    visibility: VisibilityTag::None,
1505                    expected_unused_reason: None,
1506                    span: oxc_span::Span::new(0, 20),
1507                    members: vec![],
1508                    is_side_effect_used: false,
1509                    super_class: None,
1510                }],
1511                ..Default::default()
1512            },
1513        ];
1514
1515        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1516        assert_eq!(graph.edge_count(), 1);
1517        let styles = &graph.modules[1];
1518        let export = &styles.exports[0];
1519        assert!(
1520            export.references.is_empty(),
1521            "side-effect import should not reference named exports"
1522        );
1523    }
1524
1525    #[test]
1526    fn graph_multiple_entry_points() {
1527        let files = vec![
1528            DiscoveredFile {
1529                id: FileId(0),
1530                path: PathBuf::from("/project/main.ts"),
1531                size_bytes: 100,
1532            },
1533            DiscoveredFile {
1534                id: FileId(1),
1535                path: PathBuf::from("/project/worker.ts"),
1536                size_bytes: 100,
1537            },
1538            DiscoveredFile {
1539                id: FileId(2),
1540                path: PathBuf::from("/project/shared.ts"),
1541                size_bytes: 50,
1542            },
1543        ];
1544        let entry_points = vec![
1545            EntryPoint {
1546                path: PathBuf::from("/project/main.ts"),
1547                source: EntryPointSource::PackageJsonMain,
1548            },
1549            EntryPoint {
1550                path: PathBuf::from("/project/worker.ts"),
1551                source: EntryPointSource::PackageJsonMain,
1552            },
1553        ];
1554        let resolved_modules = vec![
1555            ResolvedModule {
1556                file_id: FileId(0),
1557                path: PathBuf::from("/project/main.ts"),
1558                resolved_imports: vec![ResolvedImport {
1559                    info: ImportInfo {
1560                        source: "./shared".to_string(),
1561                        imported_name: ImportedName::Named("helper".to_string()),
1562                        local_name: "helper".to_string(),
1563                        is_type_only: false,
1564                        from_style: false,
1565                        span: oxc_span::Span::new(0, 10),
1566                        source_span: oxc_span::Span::default(),
1567                    },
1568                    target: ResolveResult::InternalModule(FileId(2)),
1569                }],
1570                ..Default::default()
1571            },
1572            ResolvedModule {
1573                file_id: FileId(1),
1574                path: PathBuf::from("/project/worker.ts"),
1575                ..Default::default()
1576            },
1577            ResolvedModule {
1578                file_id: FileId(2),
1579                path: PathBuf::from("/project/shared.ts"),
1580                exports: vec![fallow_types::extract::ExportInfo {
1581                    name: ExportName::Named("helper".to_string()),
1582                    local_name: Some("helper".to_string()),
1583                    is_type_only: false,
1584                    visibility: VisibilityTag::None,
1585                    expected_unused_reason: None,
1586                    span: oxc_span::Span::new(0, 20),
1587                    members: vec![],
1588                    is_side_effect_used: false,
1589                    super_class: None,
1590                }],
1591                ..Default::default()
1592            },
1593        ];
1594
1595        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1596        assert!(graph.modules[0].is_entry_point());
1597        assert!(graph.modules[1].is_entry_point());
1598        assert!(!graph.modules[2].is_entry_point());
1599        assert!(graph.modules[0].is_reachable());
1600        assert!(graph.modules[1].is_reachable());
1601        assert!(graph.modules[2].is_reachable());
1602    }
1603}