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