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