Skip to main content

fallow_graph/
graph.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
6use std::collections::VecDeque;
7
8use rustc_hash::{FxHashMap, FxHashSet};
9use std::ops::Range;
10use std::path::PathBuf;
11
12use fixedbitset::FixedBitSet;
13
14use crate::resolve::{ResolveResult, ResolvedModule};
15use fallow_types::discover::{DiscoveredFile, EntryPoint, FileId};
16use fallow_types::extract::{ExportName, ImportedName};
17
18/// The core module dependency graph.
19#[derive(Debug)]
20pub struct ModuleGraph {
21    /// All modules indexed by `FileId`.
22    pub modules: Vec<ModuleNode>,
23    /// Flat edge storage for cache-friendly iteration.
24    edges: Vec<Edge>,
25    /// Maps npm package names to the set of `FileId`s that import them.
26    pub package_usage: FxHashMap<String, Vec<FileId>>,
27    /// Maps npm package names to the set of `FileId`s that import them with type-only imports.
28    /// A package appearing here but not in `package_usage` (or only in both) indicates
29    /// it's only used for types and could be a devDependency.
30    pub type_only_package_usage: FxHashMap<String, Vec<FileId>>,
31    /// All entry point `FileId`s.
32    pub entry_points: FxHashSet<FileId>,
33    /// Reverse index: for each `FileId`, which files import it.
34    pub reverse_deps: Vec<Vec<FileId>>,
35    /// Precomputed: which modules have namespace imports (import * as ns).
36    namespace_imported: FixedBitSet,
37}
38
39/// A single module in the graph.
40#[derive(Debug)]
41pub struct ModuleNode {
42    /// Unique identifier for this module.
43    pub file_id: FileId,
44    /// Absolute path to the module file.
45    pub path: PathBuf,
46    /// Range into the flat `edges` array.
47    pub edge_range: Range<usize>,
48    /// Exports declared by this module.
49    pub exports: Vec<ExportSymbol>,
50    /// Re-exports from this module (export { x } from './y', export * from './z').
51    pub re_exports: Vec<ReExportEdge>,
52    /// Whether this module is an entry point.
53    pub is_entry_point: bool,
54    /// Whether this module is reachable from any entry point.
55    pub is_reachable: bool,
56    /// Whether this module has CJS exports (module.exports / exports.*).
57    pub has_cjs_exports: bool,
58}
59
60/// A re-export edge, tracking which exports are forwarded from which module.
61#[derive(Debug)]
62pub struct ReExportEdge {
63    /// The module being re-exported from.
64    pub source_file: FileId,
65    /// The name imported from the source (or "*" for star re-exports).
66    pub imported_name: String,
67    /// The name exported from this module.
68    pub exported_name: String,
69    /// Whether this is a type-only re-export.
70    pub is_type_only: bool,
71}
72
73/// An export with reference tracking.
74#[derive(Debug)]
75pub struct ExportSymbol {
76    /// The exported name (named or default).
77    pub name: ExportName,
78    /// Whether this is a type-only export.
79    pub is_type_only: bool,
80    /// Source span of the export declaration.
81    pub span: oxc_span::Span,
82    /// Which files reference this export.
83    pub references: Vec<SymbolReference>,
84    /// Members of this export (enum members, class members).
85    pub members: Vec<fallow_types::extract::MemberInfo>,
86}
87
88/// A reference to an export from another file.
89#[derive(Debug, Clone)]
90pub struct SymbolReference {
91    /// The file that references this export.
92    pub from_file: FileId,
93    /// How the export is referenced.
94    pub kind: ReferenceKind,
95    /// Byte span of the import statement in the referencing file.
96    /// Used by the LSP to locate references for Code Lens navigation.
97    pub import_span: oxc_span::Span,
98}
99
100/// How an export is referenced.
101#[derive(Debug, Clone, PartialEq)]
102pub enum ReferenceKind {
103    /// A named import (`import { foo }`).
104    NamedImport,
105    /// A default import (`import Foo`).
106    DefaultImport,
107    /// A namespace import (`import * as ns`).
108    NamespaceImport,
109    /// A re-export (`export { foo } from './bar'`).
110    ReExport,
111    /// A dynamic import (`import('./foo')`).
112    DynamicImport,
113    /// A side-effect import (`import './styles'`).
114    SideEffectImport,
115}
116
117/// An edge in the module graph.
118#[derive(Debug)]
119struct Edge {
120    source: FileId,
121    target: FileId,
122    symbols: Vec<ImportedSymbol>,
123}
124
125/// A symbol imported across an edge.
126#[derive(Debug)]
127struct ImportedSymbol {
128    imported_name: ImportedName,
129    local_name: String,
130    /// Byte span of the import statement in the source file.
131    import_span: oxc_span::Span,
132}
133
134// Size assertions to prevent memory regressions in hot-path graph types.
135// `Edge` is stored in a flat contiguous Vec for cache-friendly traversal.
136// `ImportedSymbol` is stored in a Vec per Edge.
137// `ExportSymbol` and `SymbolReference` are stored in Vecs per module node.
138// `ReExportEdge` is stored in a Vec per module for re-export chain resolution.
139#[cfg(target_pointer_width = "64")]
140const _: () = assert!(std::mem::size_of::<Edge>() == 32);
141#[cfg(target_pointer_width = "64")]
142const _: () = assert!(std::mem::size_of::<ImportedSymbol>() == 56);
143#[cfg(target_pointer_width = "64")]
144const _: () = assert!(std::mem::size_of::<ExportSymbol>() == 88);
145#[cfg(target_pointer_width = "64")]
146const _: () = assert!(std::mem::size_of::<SymbolReference>() == 16);
147#[cfg(target_pointer_width = "64")]
148const _: () = assert!(std::mem::size_of::<ReExportEdge>() == 56);
149
150impl ModuleGraph {
151    /// Build the module graph from resolved modules and entry points.
152    pub fn build(
153        resolved_modules: &[ResolvedModule],
154        entry_points: &[EntryPoint],
155        files: &[DiscoveredFile],
156    ) -> Self {
157        let _span = tracing::info_span!("build_graph").entered();
158
159        let module_count = files.len();
160
161        // Compute the total capacity needed, accounting for workspace FileIds
162        // that may exceed files.len() if IDs are assigned beyond the file count.
163        let max_file_id = files
164            .iter()
165            .map(|f| f.id.0 as usize)
166            .max()
167            .map_or(0, |m| m + 1);
168        let total_capacity = max_file_id.max(module_count);
169
170        // Build path -> FileId index
171        let path_to_id: FxHashMap<PathBuf, FileId> =
172            files.iter().map(|f| (f.path.clone(), f.id)).collect();
173
174        // Build FileId -> ResolvedModule index
175        let module_by_id: FxHashMap<FileId, &ResolvedModule> =
176            resolved_modules.iter().map(|m| (m.file_id, m)).collect();
177
178        // Build entry point set — use path_to_id map instead of O(n) scan per entry
179        let entry_point_ids: FxHashSet<FileId> = entry_points
180            .iter()
181            .filter_map(|ep| {
182                // Try direct lookup first (fast path)
183                path_to_id.get(&ep.path).copied().or_else(|| {
184                    // Fallback: canonicalize entry point and do a direct FxHashMap lookup
185                    ep.path
186                        .canonicalize()
187                        .ok()
188                        .and_then(|c| path_to_id.get(&c).copied())
189                })
190            })
191            .collect();
192
193        // Phase 1: Build flat edge storage, module nodes, and package usage from resolved modules
194        let mut graph = Self::populate_edges(
195            files,
196            &module_by_id,
197            &entry_point_ids,
198            module_count,
199            total_capacity,
200        );
201
202        // Phase 2: Record which files reference which exports (namespace + CSS module narrowing)
203        graph.populate_references(&module_by_id, &entry_point_ids);
204
205        // Phase 3: BFS from entry points to mark reachable modules
206        graph.mark_reachable(total_capacity);
207
208        // Phase 4: Propagate references through re-export chains
209        graph.resolve_re_export_chains();
210
211        graph
212    }
213
214    /// Build flat edge storage from resolved modules.
215    ///
216    /// Creates `ModuleNode` entries, flat `Edge` storage, reverse dependency
217    /// indices, package usage maps, and the namespace-imported bitset.
218    #[allow(clippy::cognitive_complexity)] // Complex but coherent edge-population logic
219    fn populate_edges(
220        files: &[DiscoveredFile],
221        module_by_id: &FxHashMap<FileId, &ResolvedModule>,
222        entry_point_ids: &FxHashSet<FileId>,
223        module_count: usize,
224        total_capacity: usize,
225    ) -> Self {
226        let mut all_edges = Vec::new();
227        let mut modules = Vec::with_capacity(module_count);
228        let mut package_usage: FxHashMap<String, Vec<FileId>> = FxHashMap::default();
229        let mut type_only_package_usage: FxHashMap<String, Vec<FileId>> = FxHashMap::default();
230        let mut reverse_deps = vec![Vec::new(); total_capacity];
231        let mut namespace_imported = FixedBitSet::with_capacity(total_capacity);
232
233        for file in files {
234            let edge_start = all_edges.len();
235
236            if let Some(resolved) = module_by_id.get(&file.id) {
237                // Group imports by target
238                let mut edges_by_target: FxHashMap<FileId, Vec<ImportedSymbol>> =
239                    FxHashMap::default();
240
241                for import in &resolved.resolved_imports {
242                    match &import.target {
243                        ResolveResult::InternalModule(target_id) => {
244                            // Track namespace imports during edge creation
245                            if matches!(import.info.imported_name, ImportedName::Namespace) {
246                                let idx = target_id.0 as usize;
247                                if idx < total_capacity {
248                                    namespace_imported.insert(idx);
249                                }
250                            }
251                            edges_by_target
252                                .entry(*target_id)
253                                .or_default()
254                                .push(ImportedSymbol {
255                                    imported_name: import.info.imported_name.clone(),
256                                    local_name: import.info.local_name.clone(),
257                                    import_span: import.info.span,
258                                });
259                        }
260                        ResolveResult::NpmPackage(name) => {
261                            package_usage.entry(name.clone()).or_default().push(file.id);
262                            if import.info.is_type_only {
263                                type_only_package_usage
264                                    .entry(name.clone())
265                                    .or_default()
266                                    .push(file.id);
267                            }
268                        }
269                        _ => {}
270                    }
271                }
272
273                // Re-exports also create edges
274                for re_export in &resolved.re_exports {
275                    if let ResolveResult::InternalModule(target_id) = &re_export.target {
276                        // ALL re-exports use SideEffect edges to avoid marking source
277                        // exports as "used" just because they're re-exported. The
278                        // re-export chain propagation handles tracking which specific
279                        // names consumers actually import.
280                        edges_by_target
281                            .entry(*target_id)
282                            .or_default()
283                            .push(ImportedSymbol {
284                                imported_name: ImportedName::SideEffect,
285                                local_name: String::new(),
286                                import_span: oxc_span::Span::new(0, 0),
287                            });
288                    } else if let ResolveResult::NpmPackage(name) = &re_export.target {
289                        package_usage.entry(name.clone()).or_default().push(file.id);
290                        if re_export.info.is_type_only {
291                            type_only_package_usage
292                                .entry(name.clone())
293                                .or_default()
294                                .push(file.id);
295                        }
296                    }
297                }
298
299                // Dynamic imports — use the imported_name/local_name from resolution.
300                // Named imports (`const { foo } = await import('./x')`) create Named edges.
301                // Namespace imports (`const mod = await import('./x')`) create Namespace edges
302                // with a local_name, enabling member access narrowing.
303                // Side-effect imports (`await import('./x')`) create SideEffect edges.
304                for import in &resolved.resolved_dynamic_imports {
305                    if let ResolveResult::InternalModule(target_id) = &import.target {
306                        if matches!(import.info.imported_name, ImportedName::Namespace) {
307                            let idx = target_id.0 as usize;
308                            if idx < total_capacity {
309                                namespace_imported.insert(idx);
310                            }
311                        }
312                        edges_by_target
313                            .entry(*target_id)
314                            .or_default()
315                            .push(ImportedSymbol {
316                                imported_name: import.info.imported_name.clone(),
317                                local_name: import.info.local_name.clone(),
318                                import_span: import.info.span,
319                            });
320                    }
321                }
322
323                // Dynamic import patterns (template literals, string concat, import.meta.glob)
324                for (_pattern, matched_ids) in &resolved.resolved_dynamic_patterns {
325                    for target_id in matched_ids {
326                        let idx = target_id.0 as usize;
327                        if idx < total_capacity {
328                            namespace_imported.insert(idx);
329                        }
330                        edges_by_target
331                            .entry(*target_id)
332                            .or_default()
333                            .push(ImportedSymbol {
334                                imported_name: ImportedName::Namespace,
335                                local_name: String::new(),
336                                import_span: oxc_span::Span::new(0, 0),
337                            });
338                    }
339                }
340
341                // Sort by target FileId for deterministic edge order across runs
342                let mut sorted_edges: Vec<_> = edges_by_target.into_iter().collect();
343                sorted_edges.sort_by_key(|(target_id, _)| target_id.0);
344
345                for (target_id, symbols) in sorted_edges {
346                    all_edges.push(Edge {
347                        source: file.id,
348                        target: target_id,
349                        symbols,
350                    });
351
352                    if (target_id.0 as usize) < reverse_deps.len() {
353                        reverse_deps[target_id.0 as usize].push(file.id);
354                    }
355                }
356            }
357
358            let edge_end = all_edges.len();
359
360            let mut exports: Vec<ExportSymbol> = module_by_id
361                .get(&file.id)
362                .map(|m| {
363                    m.exports
364                        .iter()
365                        .map(|e| ExportSymbol {
366                            name: e.name.clone(),
367                            is_type_only: e.is_type_only,
368                            span: e.span,
369                            references: Vec::new(),
370                            members: e.members.clone(),
371                        })
372                        .collect()
373                })
374                .unwrap_or_default();
375
376            // Create ExportSymbol entries for re-exports so that consumers
377            // importing from this barrel can have their references attached.
378            // Without this, `export { Foo } from './source'` on a barrel would
379            // not be trackable as an export of the barrel module.
380            if let Some(resolved) = module_by_id.get(&file.id) {
381                for re in &resolved.re_exports {
382                    // Skip star re-exports without an alias (`export * from './x'`)
383                    // — they don't create a named export on the barrel.
384                    // But `export * as name from './x'` does create one.
385                    if re.info.exported_name == "*" {
386                        continue;
387                    }
388
389                    // Avoid duplicates: if an export with this name already exists
390                    // (e.g. the module both declares and re-exports the same name),
391                    // skip creating another one.
392                    let export_name = if re.info.exported_name == "default" {
393                        ExportName::Default
394                    } else {
395                        ExportName::Named(re.info.exported_name.clone())
396                    };
397                    let already_exists = exports.iter().any(|e| e.name == export_name);
398                    if already_exists {
399                        continue;
400                    }
401
402                    exports.push(ExportSymbol {
403                        name: export_name,
404                        is_type_only: re.info.is_type_only,
405                        span: oxc_span::Span::new(0, 0), // re-exports don't have a meaningful span on the barrel
406                        references: Vec::new(),
407                        members: Vec::new(),
408                    });
409                }
410            }
411
412            let has_cjs_exports = module_by_id
413                .get(&file.id)
414                .is_some_and(|m| m.has_cjs_exports);
415
416            // Build re-export edges
417            let re_export_edges: Vec<ReExportEdge> = module_by_id
418                .get(&file.id)
419                .map(|m| {
420                    m.re_exports
421                        .iter()
422                        .filter_map(|re| {
423                            if let ResolveResult::InternalModule(target_id) = &re.target {
424                                Some(ReExportEdge {
425                                    source_file: *target_id,
426                                    imported_name: re.info.imported_name.clone(),
427                                    exported_name: re.info.exported_name.clone(),
428                                    is_type_only: re.info.is_type_only,
429                                })
430                            } else {
431                                None
432                            }
433                        })
434                        .collect()
435                })
436                .unwrap_or_default();
437
438            modules.push(ModuleNode {
439                file_id: file.id,
440                path: file.path.clone(),
441                edge_range: edge_start..edge_end,
442                exports,
443                re_exports: re_export_edges,
444                is_entry_point: entry_point_ids.contains(&file.id),
445                is_reachable: false,
446                has_cjs_exports,
447            });
448        }
449
450        Self {
451            modules,
452            edges: all_edges,
453            package_usage,
454            type_only_package_usage,
455            entry_points: entry_point_ids.clone(),
456            reverse_deps,
457            namespace_imported,
458        }
459    }
460
461    /// Record which files reference which exports from edges.
462    ///
463    /// Walks every edge and attaches `SymbolReference` entries to the target
464    /// module's exports. Includes namespace import narrowing (member access
465    /// tracking) and CSS Module default-import narrowing.
466    #[allow(clippy::cognitive_complexity)]
467    fn populate_references(
468        &mut self,
469        module_by_id: &FxHashMap<FileId, &ResolvedModule>,
470        entry_point_ids: &FxHashSet<FileId>,
471    ) {
472        for edge_idx in 0..self.edges.len() {
473            let source_id = self.edges[edge_idx].source;
474            let target_idx = self.edges[edge_idx].target.0 as usize;
475            if target_idx >= self.modules.len() {
476                continue;
477            }
478            for sym_idx in 0..self.edges[edge_idx].symbols.len() {
479                let sym_imported_name = self.edges[edge_idx].symbols[sym_idx].imported_name.clone();
480                let sym_local_name = self.edges[edge_idx].symbols[sym_idx].local_name.clone();
481                let sym_import_span = self.edges[edge_idx].symbols[sym_idx].import_span;
482
483                let ref_kind = match &sym_imported_name {
484                    ImportedName::Named(_) => ReferenceKind::NamedImport,
485                    ImportedName::Default => ReferenceKind::DefaultImport,
486                    ImportedName::Namespace => ReferenceKind::NamespaceImport,
487                    ImportedName::SideEffect => ReferenceKind::SideEffectImport,
488                };
489
490                let target_module = &mut self.modules[target_idx];
491
492                // Match to specific export
493                if let Some(export) = target_module
494                    .exports
495                    .iter_mut()
496                    .find(|e| export_matches(&e.name, &sym_imported_name))
497                {
498                    export.references.push(SymbolReference {
499                        from_file: source_id,
500                        kind: ref_kind,
501                        import_span: sym_import_span,
502                    });
503                }
504
505                // Namespace imports: check if we can narrow to specific member accesses.
506                // `import * as ns from './x'; ns.foo; ns.bar` → only mark foo, bar as used.
507                // If the namespace variable is re-exported (`export { ns }`) or no member
508                // accesses are found, conservatively mark ALL exports as used.
509                if matches!(sym_imported_name, ImportedName::Namespace)
510                    && !sym_local_name.is_empty()
511                {
512                    let local_name = &sym_local_name;
513                    let source_mod = module_by_id.get(&source_id);
514                    let accessed_members: Vec<String> = source_mod
515                        .map(|m| {
516                            m.member_accesses
517                                .iter()
518                                .filter(|ma| ma.object == *local_name)
519                                .map(|ma| ma.member.clone())
520                                .collect()
521                        })
522                        .unwrap_or_default();
523
524                    // Check if the namespace variable is re-exported (export { ns } or export default ns)
525                    // from a NON-entry-point file. If the importing file IS an entry point,
526                    // the re-export is for external consumption and doesn't prove internal usage.
527                    let is_re_exported_from_non_entry = source_mod.is_some_and(|m| {
528                        m.exports
529                            .iter()
530                            .any(|e| e.local_name.as_deref() == Some(local_name.as_str()))
531                    }) && !entry_point_ids.contains(&source_id);
532
533                    // For entry point files with no member accesses, the namespace
534                    // is purely re-exported for external use — don't mark all exports
535                    // as used internally. The `export *` path handles individual tracking.
536                    let is_entry_with_no_access =
537                        accessed_members.is_empty() && entry_point_ids.contains(&source_id);
538
539                    if !is_entry_with_no_access
540                        && (accessed_members.is_empty() || is_re_exported_from_non_entry)
541                    {
542                        // Can't narrow — mark all exports as referenced (conservative)
543                        for export in &mut self.modules[target_idx].exports {
544                            if export.references.iter().all(|r| r.from_file != source_id) {
545                                export.references.push(SymbolReference {
546                                    from_file: source_id,
547                                    kind: ReferenceKind::NamespaceImport,
548                                    import_span: sym_import_span,
549                                });
550                            }
551                        }
552                    } else {
553                        // Narrow: only mark accessed members as referenced
554                        let mut found_members: FxHashSet<String> = FxHashSet::default();
555                        for export in &mut self.modules[target_idx].exports {
556                            let name_str = export.name.to_string();
557                            if accessed_members.contains(&name_str) {
558                                found_members.insert(name_str);
559                                if export.references.iter().all(|r| r.from_file != source_id) {
560                                    export.references.push(SymbolReference {
561                                        from_file: source_id,
562                                        kind: ReferenceKind::NamespaceImport,
563                                        import_span: sym_import_span,
564                                    });
565                                }
566                            }
567                        }
568
569                        // For members not found on the target (e.g., barrel with
570                        // `export *` that has no own exports for these names),
571                        // create synthetic ExportSymbol entries so that
572                        // resolve_re_export_chains can propagate them to the
573                        // actual source modules.
574                        let target_has_star_re_exports = self.modules[target_idx]
575                            .re_exports
576                            .iter()
577                            .any(|re| re.exported_name == "*");
578                        if target_has_star_re_exports {
579                            for member in &accessed_members {
580                                if !found_members.contains(member) {
581                                    let export_name = if member == "default" {
582                                        ExportName::Default
583                                    } else {
584                                        ExportName::Named(member.clone())
585                                    };
586                                    self.modules[target_idx].exports.push(ExportSymbol {
587                                        name: export_name,
588                                        is_type_only: false,
589                                        span: oxc_span::Span::new(0, 0),
590                                        references: vec![SymbolReference {
591                                            from_file: source_id,
592                                            kind: ReferenceKind::NamespaceImport,
593                                            import_span: sym_import_span,
594                                        }],
595                                        members: Vec::new(),
596                                    });
597                                }
598                            }
599                        }
600                    }
601                } else if matches!(sym_imported_name, ImportedName::Namespace) {
602                    // No local name available — mark all (conservative)
603                    for export in &mut self.modules[target_idx].exports {
604                        if export.references.iter().all(|r| r.from_file != source_id) {
605                            export.references.push(SymbolReference {
606                                from_file: source_id,
607                                kind: ReferenceKind::NamespaceImport,
608                                import_span: sym_import_span,
609                            });
610                        }
611                    }
612                }
613
614                // CSS Module default imports: `import styles from './Button.module.css'`
615                // Member accesses like `styles.primary` should mark the `primary` named
616                // export as referenced, since CSS module default imports act as namespace
617                // objects where each property corresponds to a class name (named export).
618                if matches!(sym_imported_name, ImportedName::Default)
619                    && !sym_local_name.is_empty()
620                    && is_css_module_path(&self.modules[target_idx].path)
621                {
622                    let local_name = &sym_local_name;
623                    let source_mod = module_by_id.get(&source_id);
624                    let is_whole_object = source_mod
625                        .is_some_and(|m| m.whole_object_uses.iter().any(|n| n == local_name));
626                    let accessed_members: Vec<String> = source_mod
627                        .map(|m| {
628                            m.member_accesses
629                                .iter()
630                                .filter(|ma| ma.object == *local_name)
631                                .map(|ma| ma.member.clone())
632                                .collect()
633                        })
634                        .unwrap_or_default();
635                    if is_whole_object || accessed_members.is_empty() {
636                        for export in &mut self.modules[target_idx].exports {
637                            if export.references.iter().all(|r| r.from_file != source_id) {
638                                export.references.push(SymbolReference {
639                                    from_file: source_id,
640                                    kind: ReferenceKind::DefaultImport,
641                                    import_span: sym_import_span,
642                                });
643                            }
644                        }
645                    } else {
646                        for export in &mut self.modules[target_idx].exports {
647                            let name_str = export.name.to_string();
648                            if accessed_members.contains(&name_str)
649                                && export.references.iter().all(|r| r.from_file != source_id)
650                            {
651                                export.references.push(SymbolReference {
652                                    from_file: source_id,
653                                    kind: ReferenceKind::DefaultImport,
654                                    import_span: sym_import_span,
655                                });
656                            }
657                        }
658                    }
659                }
660            }
661        }
662    }
663
664    /// Mark modules reachable from entry points via BFS.
665    fn mark_reachable(&mut self, total_capacity: usize) {
666        let mut visited = FixedBitSet::with_capacity(total_capacity);
667        let mut queue = VecDeque::new();
668
669        for &ep_id in &self.entry_points {
670            if (ep_id.0 as usize) < total_capacity {
671                visited.insert(ep_id.0 as usize);
672                queue.push_back(ep_id);
673            }
674        }
675
676        while let Some(file_id) = queue.pop_front() {
677            if (file_id.0 as usize) >= self.modules.len() {
678                continue;
679            }
680            let module = &self.modules[file_id.0 as usize];
681            for edge in &self.edges[module.edge_range.clone()] {
682                let target_idx = edge.target.0 as usize;
683                if target_idx < total_capacity && !visited.contains(target_idx) {
684                    visited.insert(target_idx);
685                    queue.push_back(edge.target);
686                }
687            }
688        }
689
690        for (idx, module) in self.modules.iter_mut().enumerate() {
691            module.is_reachable = visited.contains(idx);
692        }
693    }
694
695    /// Resolve re-export chains: when module A re-exports from B,
696    /// any reference to A's re-exported symbol should also count as a reference
697    /// to B's original export (and transitively through the chain).
698    fn resolve_re_export_chains(&mut self) {
699        // Collect re-export info: (barrel_file_id, source_file_id, imported_name, exported_name)
700        let re_export_info: Vec<(FileId, FileId, String, String)> = self
701            .modules
702            .iter()
703            .flat_map(|m| {
704                m.re_exports.iter().map(move |re| {
705                    (
706                        m.file_id,
707                        re.source_file,
708                        re.imported_name.clone(),
709                        re.exported_name.clone(),
710                    )
711                })
712            })
713            .collect();
714
715        if re_export_info.is_empty() {
716            return;
717        }
718
719        // For each re-export, if the barrel's exported symbol has references,
720        // propagate those references to the source module's original export.
721        // We iterate until no new references are added (handles chains).
722        let mut changed = true;
723        let max_iterations = 20; // prevent infinite loops on cycles
724        let mut iteration = 0;
725        // Reuse a single HashSet across iterations to avoid repeated allocations.
726        // In barrel-heavy monorepos, this loop can run up to max_iterations × re_export_info.len()
727        // × target_exports.len() times — reusing with .clear() avoids O(n) allocations.
728        let mut existing_refs: FxHashSet<FileId> = FxHashSet::default();
729
730        while changed && iteration < max_iterations {
731            changed = false;
732            iteration += 1;
733
734            for &(barrel_id, source_id, ref imported_name, ref exported_name) in &re_export_info {
735                let barrel_idx = barrel_id.0 as usize;
736                let source_idx = source_id.0 as usize;
737
738                if barrel_idx >= self.modules.len() || source_idx >= self.modules.len() {
739                    continue;
740                }
741
742                if exported_name == "*" {
743                    // Star re-export (`export * from './source'`): the barrel has no named
744                    // ExportSymbol entries for the re-exported names. Instead, look at which
745                    // named imports other modules make from this barrel and propagate each
746                    // to the matching export in the source module.
747
748                    // Collect named imports that target the barrel from ALL edges
749                    let barrel_file_id = self.modules[barrel_idx].file_id;
750                    let named_refs: Vec<(String, SymbolReference)> = self
751                        .edges
752                        .iter()
753                        .filter(|edge| edge.target == barrel_file_id)
754                        .flat_map(|edge| {
755                            edge.symbols.iter().filter_map(move |sym| {
756                                if let ImportedName::Named(name) = &sym.imported_name {
757                                    Some((
758                                        name.clone(),
759                                        SymbolReference {
760                                            from_file: edge.source,
761                                            kind: ReferenceKind::NamedImport,
762                                            import_span: sym.import_span,
763                                        },
764                                    ))
765                                } else {
766                                    None
767                                }
768                            })
769                        })
770                        .collect();
771
772                    // Also check for references already on barrel exports from
773                    // prior chain propagation (handles multi-level barrel chains)
774                    let barrel_export_refs: Vec<(String, SymbolReference)> = self.modules
775                        [barrel_idx]
776                        .exports
777                        .iter()
778                        .flat_map(|e| {
779                            e.references
780                                .iter()
781                                .map(move |r| (e.name.to_string(), r.clone()))
782                        })
783                        .collect();
784
785                    // Check if the source module itself has star re-exports (for multi-level chains).
786                    // If so, we may need to create synthetic ExportSymbol entries on it so
787                    // that the next iteration can propagate names further down the chain.
788                    let source_has_star_re_exports = self.modules[source_idx]
789                        .re_exports
790                        .iter()
791                        .any(|re| re.exported_name == "*");
792
793                    // Propagate each named import to the matching source export.
794                    // For multi-level star re-export chains (e.g., index -> intermediate -> source),
795                    // intermediate barrels may not have ExportSymbol entries for the names being
796                    // imported. When the source has its own star re-exports, create synthetic
797                    // ExportSymbol entries so the iterative loop can propagate further on the
798                    // next pass.
799                    let source = &mut self.modules[source_idx];
800                    for (name, ref_item) in named_refs.iter().chain(barrel_export_refs.iter()) {
801                        let export_name = if name == "default" {
802                            ExportName::Default
803                        } else {
804                            ExportName::Named(name.clone())
805                        };
806                        if let Some(export) =
807                            source.exports.iter_mut().find(|e| e.name == export_name)
808                        {
809                            if export
810                                .references
811                                .iter()
812                                .all(|r| r.from_file != ref_item.from_file)
813                            {
814                                export.references.push(ref_item.clone());
815                                changed = true;
816                            }
817                        } else if source_has_star_re_exports {
818                            // The source module doesn't have this export directly but
819                            // it has star re-exports — create a synthetic ExportSymbol
820                            // so the name can propagate through the chain on the next
821                            // iteration.
822                            source.exports.push(ExportSymbol {
823                                name: export_name,
824                                is_type_only: false,
825                                span: oxc_span::Span::new(0, 0),
826                                references: vec![ref_item.clone()],
827                                members: Vec::new(),
828                            });
829                            changed = true;
830                        }
831                    }
832                } else {
833                    // Named re-export: find references to the exported name on the barrel
834                    let refs_on_barrel: Vec<SymbolReference> = {
835                        let barrel = &self.modules[barrel_idx];
836                        barrel
837                            .exports
838                            .iter()
839                            .filter(|e| e.name.to_string() == *exported_name)
840                            .flat_map(|e| e.references.clone())
841                            .collect()
842                    };
843
844                    if refs_on_barrel.is_empty() {
845                        continue;
846                    }
847
848                    // Propagate to source module's export
849                    let source = &mut self.modules[source_idx];
850                    let target_exports: Vec<usize> = source
851                        .exports
852                        .iter()
853                        .enumerate()
854                        .filter(|(_, e)| e.name.to_string() == *imported_name)
855                        .map(|(i, _)| i)
856                        .collect();
857
858                    for export_idx in target_exports {
859                        existing_refs.clear();
860                        existing_refs.extend(
861                            source.exports[export_idx]
862                                .references
863                                .iter()
864                                .map(|r| r.from_file),
865                        );
866                        for ref_item in &refs_on_barrel {
867                            if !existing_refs.contains(&ref_item.from_file) {
868                                source.exports[export_idx].references.push(ref_item.clone());
869                                changed = true;
870                            }
871                        }
872                    }
873                }
874            }
875        }
876
877        if iteration >= max_iterations {
878            tracing::warn!(
879                iterations = max_iterations,
880                "Re-export chain resolution hit iteration limit, some chains may be incomplete"
881            );
882        }
883    }
884
885    /// Total number of modules.
886    pub const fn module_count(&self) -> usize {
887        self.modules.len()
888    }
889
890    /// Total number of edges.
891    pub const fn edge_count(&self) -> usize {
892        self.edges.len()
893    }
894
895    /// Check if any importer uses `import * as ns` for this module.
896    /// Uses precomputed bitset — O(1) lookup.
897    pub fn has_namespace_import(&self, file_id: FileId) -> bool {
898        let idx = file_id.0 as usize;
899        if idx >= self.namespace_imported.len() {
900            return false;
901        }
902        self.namespace_imported.contains(idx)
903    }
904
905    /// Get the target `FileId`s of all outgoing edges for a module.
906    pub fn edges_for(&self, file_id: FileId) -> Vec<FileId> {
907        let idx = file_id.0 as usize;
908        if idx >= self.modules.len() {
909            return Vec::new();
910        }
911        let range = &self.modules[idx].edge_range;
912        self.edges[range.clone()].iter().map(|e| e.target).collect()
913    }
914
915    /// Find all circular dependency cycles in the module graph.
916    ///
917    /// Uses an iterative implementation of Tarjan's strongly connected components
918    /// algorithm (O(V + E)) to find all SCCs with 2 or more nodes. Each such SCC
919    /// represents a set of files involved in a circular dependency.
920    ///
921    /// Returns cycles sorted by length (shortest first), with files within each
922    /// cycle sorted by path for deterministic output.
923    pub fn find_cycles(&self) -> Vec<Vec<FileId>> {
924        let n = self.modules.len();
925        if n == 0 {
926            return Vec::new();
927        }
928
929        // Tarjan's SCC state
930        let mut index_counter: u32 = 0;
931        let mut indices: Vec<u32> = vec![u32::MAX; n]; // u32::MAX = undefined
932        let mut lowlinks: Vec<u32> = vec![0; n];
933        let mut on_stack = FixedBitSet::with_capacity(n);
934        let mut stack: Vec<usize> = Vec::new();
935        let mut sccs: Vec<Vec<FileId>> = Vec::new();
936
937        // Iterative DFS stack frame
938        struct Frame {
939            node: usize,
940            succ_pos: usize,
941            succ_end: usize,
942        }
943
944        // Pre-collect all successors (deduplicated) into a flat vec for cache-friendly access.
945        let mut all_succs: Vec<usize> = Vec::with_capacity(self.edges.len());
946        let mut succ_ranges: Vec<Range<usize>> = Vec::with_capacity(n);
947        let mut seen_set = FxHashSet::default();
948        for module in &self.modules {
949            let start = all_succs.len();
950            seen_set.clear();
951            for edge in &self.edges[module.edge_range.clone()] {
952                let target = edge.target.0 as usize;
953                if target < n && seen_set.insert(target) {
954                    all_succs.push(target);
955                }
956            }
957            let end = all_succs.len();
958            succ_ranges.push(start..end);
959        }
960
961        let mut dfs_stack: Vec<Frame> = Vec::new();
962
963        for start_node in 0..n {
964            if indices[start_node] != u32::MAX {
965                continue;
966            }
967
968            // Push the starting node
969            indices[start_node] = index_counter;
970            lowlinks[start_node] = index_counter;
971            index_counter += 1;
972            on_stack.insert(start_node);
973            stack.push(start_node);
974
975            let range = &succ_ranges[start_node];
976            dfs_stack.push(Frame {
977                node: start_node,
978                succ_pos: range.start,
979                succ_end: range.end,
980            });
981
982            while let Some(frame) = dfs_stack.last_mut() {
983                if frame.succ_pos < frame.succ_end {
984                    let w = all_succs[frame.succ_pos];
985                    frame.succ_pos += 1;
986
987                    if indices[w] == u32::MAX {
988                        // Tree edge: push w onto the DFS stack
989                        indices[w] = index_counter;
990                        lowlinks[w] = index_counter;
991                        index_counter += 1;
992                        on_stack.insert(w);
993                        stack.push(w);
994
995                        let range = &succ_ranges[w];
996                        dfs_stack.push(Frame {
997                            node: w,
998                            succ_pos: range.start,
999                            succ_end: range.end,
1000                        });
1001                    } else if on_stack.contains(w) {
1002                        // Back edge: update lowlink
1003                        let v = frame.node;
1004                        lowlinks[v] = lowlinks[v].min(indices[w]);
1005                    }
1006                } else {
1007                    // All successors processed — pop this frame
1008                    let v = frame.node;
1009                    let v_lowlink = lowlinks[v];
1010                    let v_index = indices[v];
1011                    dfs_stack.pop();
1012
1013                    // Update parent's lowlink
1014                    if let Some(parent) = dfs_stack.last_mut() {
1015                        lowlinks[parent.node] = lowlinks[parent.node].min(v_lowlink);
1016                    }
1017
1018                    // If v is a root node, pop the SCC
1019                    if v_lowlink == v_index {
1020                        let mut scc = Vec::new();
1021                        loop {
1022                            let w = stack.pop().expect("SCC stack should not be empty");
1023                            on_stack.set(w, false);
1024                            scc.push(FileId(w as u32));
1025                            if w == v {
1026                                break;
1027                            }
1028                        }
1029                        // Only report cycles of length >= 2
1030                        if scc.len() >= 2 {
1031                            sccs.push(scc);
1032                        }
1033                    }
1034                }
1035            }
1036        }
1037
1038        // Phase 2: Enumerate individual elementary cycles within each SCC.
1039        // For small SCCs (len == 2), there's exactly one cycle.
1040        // For larger SCCs, use bounded DFS to find up to MAX_CYCLES_PER_SCC cycles.
1041        const MAX_CYCLES_PER_SCC: usize = 20;
1042
1043        let mut result: Vec<Vec<FileId>> = Vec::new();
1044        let mut seen_cycles: FxHashSet<Vec<u32>> = FxHashSet::default();
1045
1046        for scc in &sccs {
1047            if scc.len() == 2 {
1048                let mut cycle = vec![scc[0].0 as usize, scc[1].0 as usize];
1049                // Canonical: smallest path first
1050                if self.modules[cycle[1]].path < self.modules[cycle[0]].path {
1051                    cycle.swap(0, 1);
1052                }
1053                let key: Vec<u32> = cycle.iter().map(|&n| n as u32).collect();
1054                if seen_cycles.insert(key) {
1055                    result.push(cycle.into_iter().map(|n| FileId(n as u32)).collect());
1056                }
1057                continue;
1058            }
1059
1060            let scc_nodes: Vec<usize> = scc.iter().map(|id| id.0 as usize).collect();
1061            let elementary = enumerate_elementary_cycles(
1062                &scc_nodes,
1063                &all_succs,
1064                &succ_ranges,
1065                MAX_CYCLES_PER_SCC,
1066                &self.modules,
1067            );
1068
1069            for cycle in elementary {
1070                let key: Vec<u32> = cycle.iter().map(|&n| n as u32).collect();
1071                if seen_cycles.insert(key) {
1072                    result.push(cycle.into_iter().map(|n| FileId(n as u32)).collect());
1073                }
1074            }
1075        }
1076
1077        // Sort: shortest first, then by first file path
1078        result.sort_by(|a, b| {
1079            a.len().cmp(&b.len()).then_with(|| {
1080                self.modules[a[0].0 as usize]
1081                    .path
1082                    .cmp(&self.modules[b[0].0 as usize].path)
1083            })
1084        });
1085
1086        result
1087    }
1088}
1089
1090/// Rotate a cycle so the node with the smallest path is first (canonical form for dedup).
1091fn canonical_cycle(cycle: &[usize], modules: &[ModuleNode]) -> Vec<usize> {
1092    if cycle.is_empty() {
1093        return Vec::new();
1094    }
1095    let min_pos = cycle
1096        .iter()
1097        .enumerate()
1098        .min_by(|(_, a), (_, b)| modules[**a].path.cmp(&modules[**b].path))
1099        .map_or(0, |(i, _)| i);
1100    let mut result = cycle[min_pos..].to_vec();
1101    result.extend_from_slice(&cycle[..min_pos]);
1102    result
1103}
1104
1105/// Enumerate individual elementary cycles within an SCC using depth-limited DFS.
1106///
1107/// Uses iterative deepening: first finds all 2-node cycles, then 3-node, etc.
1108/// This ensures the shortest, most actionable cycles are always found first.
1109/// Stops after `max_cycles` total cycles to bound work on dense SCCs.
1110fn enumerate_elementary_cycles(
1111    scc_nodes: &[usize],
1112    all_succs: &[usize],
1113    succ_ranges: &[Range<usize>],
1114    max_cycles: usize,
1115    modules: &[ModuleNode],
1116) -> Vec<Vec<usize>> {
1117    let scc_set: FxHashSet<usize> = scc_nodes.iter().copied().collect();
1118    let mut cycles: Vec<Vec<usize>> = Vec::new();
1119    let mut seen: FxHashSet<Vec<u32>> = FxHashSet::default();
1120
1121    // Sort start nodes by path for deterministic enumeration order
1122    let mut sorted_nodes: Vec<usize> = scc_nodes.to_vec();
1123    sorted_nodes.sort_by(|a, b| modules[*a].path.cmp(&modules[*b].path));
1124
1125    // DFS frame for iterative cycle finding
1126    struct CycleFrame {
1127        succ_pos: usize,
1128        succ_end: usize,
1129    }
1130
1131    // Iterative deepening: increase max depth from 2 up to SCC size
1132    let max_depth = scc_nodes.len().min(12); // Cap depth to avoid very long cycles
1133    for depth_limit in 2..=max_depth {
1134        if cycles.len() >= max_cycles {
1135            break;
1136        }
1137
1138        for &start in &sorted_nodes {
1139            if cycles.len() >= max_cycles {
1140                break;
1141            }
1142
1143            let mut path: Vec<usize> = vec![start];
1144            let mut path_set = FixedBitSet::with_capacity(modules.len());
1145            path_set.insert(start);
1146
1147            let range = &succ_ranges[start];
1148            let mut dfs: Vec<CycleFrame> = vec![CycleFrame {
1149                succ_pos: range.start,
1150                succ_end: range.end,
1151            }];
1152
1153            while let Some(frame) = dfs.last_mut() {
1154                if cycles.len() >= max_cycles {
1155                    break;
1156                }
1157
1158                if frame.succ_pos >= frame.succ_end {
1159                    // Backtrack
1160                    dfs.pop();
1161                    if path.len() > 1 {
1162                        let last = path.pop().unwrap();
1163                        path_set.set(last, false);
1164                    }
1165                    continue;
1166                }
1167
1168                let w = all_succs[frame.succ_pos];
1169                frame.succ_pos += 1;
1170
1171                // Only follow edges within this SCC
1172                if !scc_set.contains(&w) {
1173                    continue;
1174                }
1175
1176                if w == start && path.len() >= 2 && path.len() == depth_limit {
1177                    // Found an elementary cycle at exactly this depth
1178                    let canonical = canonical_cycle(&path, modules);
1179                    let key: Vec<u32> = canonical.iter().map(|&n| n as u32).collect();
1180                    if seen.insert(key) {
1181                        cycles.push(canonical);
1182                    }
1183                } else if !path_set.contains(w) && path.len() < depth_limit {
1184                    // Extend path (only if within depth limit)
1185                    path.push(w);
1186                    path_set.insert(w);
1187
1188                    let range = &succ_ranges[w];
1189                    dfs.push(CycleFrame {
1190                        succ_pos: range.start,
1191                        succ_end: range.end,
1192                    });
1193                }
1194            }
1195        }
1196    }
1197
1198    cycles
1199}
1200
1201/// Check if a path is a CSS Module file (`.module.css` or `.module.scss`).
1202fn is_css_module_path(path: &std::path::Path) -> bool {
1203    path.file_stem()
1204        .and_then(|s| s.to_str())
1205        .is_some_and(|stem| stem.ends_with(".module"))
1206        && path
1207            .extension()
1208            .and_then(|e| e.to_str())
1209            .is_some_and(|ext| ext == "css" || ext == "scss")
1210}
1211
1212/// Check if an export name matches an imported name.
1213fn export_matches(export: &ExportName, import: &ImportedName) -> bool {
1214    match (export, import) {
1215        (ExportName::Named(e), ImportedName::Named(i)) => e == i,
1216        (ExportName::Default, ImportedName::Default) => true,
1217        _ => false,
1218    }
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223    use super::*;
1224    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule, ResolvedReExport};
1225    use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
1226    use fallow_types::extract::{ExportName, ImportInfo, ImportedName};
1227    use std::path::PathBuf;
1228
1229    #[test]
1230    fn export_matches_named_same() {
1231        assert!(export_matches(
1232            &ExportName::Named("foo".to_string()),
1233            &ImportedName::Named("foo".to_string())
1234        ));
1235    }
1236
1237    #[test]
1238    fn export_matches_named_different() {
1239        assert!(!export_matches(
1240            &ExportName::Named("foo".to_string()),
1241            &ImportedName::Named("bar".to_string())
1242        ));
1243    }
1244
1245    #[test]
1246    fn export_matches_default() {
1247        assert!(export_matches(&ExportName::Default, &ImportedName::Default));
1248    }
1249
1250    #[test]
1251    fn export_matches_named_vs_default() {
1252        assert!(!export_matches(
1253            &ExportName::Named("foo".to_string()),
1254            &ImportedName::Default
1255        ));
1256    }
1257
1258    #[test]
1259    fn export_matches_default_vs_named() {
1260        assert!(!export_matches(
1261            &ExportName::Default,
1262            &ImportedName::Named("foo".to_string())
1263        ));
1264    }
1265
1266    #[test]
1267    fn export_matches_namespace_no_match() {
1268        assert!(!export_matches(
1269            &ExportName::Named("foo".to_string()),
1270            &ImportedName::Namespace
1271        ));
1272        assert!(!export_matches(
1273            &ExportName::Default,
1274            &ImportedName::Namespace
1275        ));
1276    }
1277
1278    #[test]
1279    fn export_matches_side_effect_no_match() {
1280        assert!(!export_matches(
1281            &ExportName::Named("foo".to_string()),
1282            &ImportedName::SideEffect
1283        ));
1284    }
1285
1286    // Helper to build a simple module graph
1287    fn build_simple_graph() -> ModuleGraph {
1288        // Two files: entry.ts imports foo from utils.ts
1289        let files = vec![
1290            DiscoveredFile {
1291                id: FileId(0),
1292                path: PathBuf::from("/project/src/entry.ts"),
1293                size_bytes: 100,
1294            },
1295            DiscoveredFile {
1296                id: FileId(1),
1297                path: PathBuf::from("/project/src/utils.ts"),
1298                size_bytes: 50,
1299            },
1300        ];
1301
1302        let entry_points = vec![EntryPoint {
1303            path: PathBuf::from("/project/src/entry.ts"),
1304            source: EntryPointSource::PackageJsonMain,
1305        }];
1306
1307        let resolved_modules = vec![
1308            ResolvedModule {
1309                file_id: FileId(0),
1310                path: PathBuf::from("/project/src/entry.ts"),
1311                exports: vec![],
1312                re_exports: vec![],
1313                resolved_imports: vec![ResolvedImport {
1314                    info: ImportInfo {
1315                        source: "./utils".to_string(),
1316                        imported_name: ImportedName::Named("foo".to_string()),
1317                        local_name: "foo".to_string(),
1318                        is_type_only: false,
1319                        span: oxc_span::Span::new(0, 10),
1320                    },
1321                    target: ResolveResult::InternalModule(FileId(1)),
1322                }],
1323                resolved_dynamic_imports: vec![],
1324                resolved_dynamic_patterns: vec![],
1325                member_accesses: vec![],
1326                whole_object_uses: vec![],
1327                has_cjs_exports: false,
1328            },
1329            ResolvedModule {
1330                file_id: FileId(1),
1331                path: PathBuf::from("/project/src/utils.ts"),
1332                exports: vec![
1333                    fallow_types::extract::ExportInfo {
1334                        name: ExportName::Named("foo".to_string()),
1335                        local_name: Some("foo".to_string()),
1336                        is_type_only: false,
1337                        span: oxc_span::Span::new(0, 20),
1338                        members: vec![],
1339                    },
1340                    fallow_types::extract::ExportInfo {
1341                        name: ExportName::Named("bar".to_string()),
1342                        local_name: Some("bar".to_string()),
1343                        is_type_only: false,
1344                        span: oxc_span::Span::new(25, 45),
1345                        members: vec![],
1346                    },
1347                ],
1348                re_exports: vec![],
1349                resolved_imports: vec![],
1350                resolved_dynamic_imports: vec![],
1351                resolved_dynamic_patterns: vec![],
1352                member_accesses: vec![],
1353                whole_object_uses: vec![],
1354                has_cjs_exports: false,
1355            },
1356        ];
1357
1358        ModuleGraph::build(&resolved_modules, &entry_points, &files)
1359    }
1360
1361    #[test]
1362    fn graph_module_count() {
1363        let graph = build_simple_graph();
1364        assert_eq!(graph.module_count(), 2);
1365    }
1366
1367    #[test]
1368    fn graph_edge_count() {
1369        let graph = build_simple_graph();
1370        assert_eq!(graph.edge_count(), 1);
1371    }
1372
1373    #[test]
1374    fn graph_entry_point_is_reachable() {
1375        let graph = build_simple_graph();
1376        assert!(graph.modules[0].is_entry_point);
1377        assert!(graph.modules[0].is_reachable);
1378    }
1379
1380    #[test]
1381    fn graph_imported_module_is_reachable() {
1382        let graph = build_simple_graph();
1383        assert!(!graph.modules[1].is_entry_point);
1384        assert!(graph.modules[1].is_reachable);
1385    }
1386
1387    #[test]
1388    fn graph_export_has_reference() {
1389        let graph = build_simple_graph();
1390        let utils = &graph.modules[1];
1391        let foo_export = utils
1392            .exports
1393            .iter()
1394            .find(|e| e.name.to_string() == "foo")
1395            .unwrap();
1396        assert!(
1397            !foo_export.references.is_empty(),
1398            "foo should have references"
1399        );
1400    }
1401
1402    #[test]
1403    fn graph_unused_export_no_reference() {
1404        let graph = build_simple_graph();
1405        let utils = &graph.modules[1];
1406        let bar_export = utils
1407            .exports
1408            .iter()
1409            .find(|e| e.name.to_string() == "bar")
1410            .unwrap();
1411        assert!(
1412            bar_export.references.is_empty(),
1413            "bar should have no references"
1414        );
1415    }
1416
1417    #[test]
1418    fn graph_no_namespace_import() {
1419        let graph = build_simple_graph();
1420        assert!(!graph.has_namespace_import(FileId(0)));
1421        assert!(!graph.has_namespace_import(FileId(1)));
1422    }
1423
1424    #[test]
1425    fn graph_has_namespace_import() {
1426        let files = vec![
1427            DiscoveredFile {
1428                id: FileId(0),
1429                path: PathBuf::from("/project/entry.ts"),
1430                size_bytes: 100,
1431            },
1432            DiscoveredFile {
1433                id: FileId(1),
1434                path: PathBuf::from("/project/utils.ts"),
1435                size_bytes: 50,
1436            },
1437        ];
1438
1439        let entry_points = vec![EntryPoint {
1440            path: PathBuf::from("/project/entry.ts"),
1441            source: EntryPointSource::PackageJsonMain,
1442        }];
1443
1444        let resolved_modules = vec![
1445            ResolvedModule {
1446                file_id: FileId(0),
1447                path: PathBuf::from("/project/entry.ts"),
1448                exports: vec![],
1449                re_exports: vec![],
1450                resolved_imports: vec![ResolvedImport {
1451                    info: ImportInfo {
1452                        source: "./utils".to_string(),
1453                        imported_name: ImportedName::Namespace,
1454                        local_name: "utils".to_string(),
1455                        is_type_only: false,
1456                        span: oxc_span::Span::new(0, 10),
1457                    },
1458                    target: ResolveResult::InternalModule(FileId(1)),
1459                }],
1460                resolved_dynamic_imports: vec![],
1461                resolved_dynamic_patterns: vec![],
1462                member_accesses: vec![],
1463                whole_object_uses: vec![],
1464                has_cjs_exports: false,
1465            },
1466            ResolvedModule {
1467                file_id: FileId(1),
1468                path: PathBuf::from("/project/utils.ts"),
1469                exports: vec![fallow_types::extract::ExportInfo {
1470                    name: ExportName::Named("foo".to_string()),
1471                    local_name: Some("foo".to_string()),
1472                    is_type_only: false,
1473                    span: oxc_span::Span::new(0, 20),
1474                    members: vec![],
1475                }],
1476                re_exports: vec![],
1477                resolved_imports: vec![],
1478                resolved_dynamic_imports: vec![],
1479                resolved_dynamic_patterns: vec![],
1480                member_accesses: vec![],
1481                whole_object_uses: vec![],
1482                has_cjs_exports: false,
1483            },
1484        ];
1485
1486        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1487        assert!(
1488            graph.has_namespace_import(FileId(1)),
1489            "utils should have namespace import"
1490        );
1491    }
1492
1493    #[test]
1494    fn graph_has_namespace_import_out_of_bounds() {
1495        let graph = build_simple_graph();
1496        assert!(!graph.has_namespace_import(FileId(999)));
1497    }
1498
1499    #[test]
1500    fn graph_unreachable_module() {
1501        // Three files: entry imports utils, orphan is not imported
1502        let files = vec![
1503            DiscoveredFile {
1504                id: FileId(0),
1505                path: PathBuf::from("/project/entry.ts"),
1506                size_bytes: 100,
1507            },
1508            DiscoveredFile {
1509                id: FileId(1),
1510                path: PathBuf::from("/project/utils.ts"),
1511                size_bytes: 50,
1512            },
1513            DiscoveredFile {
1514                id: FileId(2),
1515                path: PathBuf::from("/project/orphan.ts"),
1516                size_bytes: 30,
1517            },
1518        ];
1519
1520        let entry_points = vec![EntryPoint {
1521            path: PathBuf::from("/project/entry.ts"),
1522            source: EntryPointSource::PackageJsonMain,
1523        }];
1524
1525        let resolved_modules = vec![
1526            ResolvedModule {
1527                file_id: FileId(0),
1528                path: PathBuf::from("/project/entry.ts"),
1529                exports: vec![],
1530                re_exports: vec![],
1531                resolved_imports: vec![ResolvedImport {
1532                    info: ImportInfo {
1533                        source: "./utils".to_string(),
1534                        imported_name: ImportedName::Named("foo".to_string()),
1535                        local_name: "foo".to_string(),
1536                        is_type_only: false,
1537                        span: oxc_span::Span::new(0, 10),
1538                    },
1539                    target: ResolveResult::InternalModule(FileId(1)),
1540                }],
1541                resolved_dynamic_imports: vec![],
1542                resolved_dynamic_patterns: vec![],
1543                member_accesses: vec![],
1544                whole_object_uses: vec![],
1545                has_cjs_exports: false,
1546            },
1547            ResolvedModule {
1548                file_id: FileId(1),
1549                path: PathBuf::from("/project/utils.ts"),
1550                exports: vec![fallow_types::extract::ExportInfo {
1551                    name: ExportName::Named("foo".to_string()),
1552                    local_name: Some("foo".to_string()),
1553                    is_type_only: false,
1554                    span: oxc_span::Span::new(0, 20),
1555                    members: vec![],
1556                }],
1557                re_exports: vec![],
1558                resolved_imports: vec![],
1559                resolved_dynamic_imports: vec![],
1560                resolved_dynamic_patterns: vec![],
1561                member_accesses: vec![],
1562                whole_object_uses: vec![],
1563                has_cjs_exports: false,
1564            },
1565            ResolvedModule {
1566                file_id: FileId(2),
1567                path: PathBuf::from("/project/orphan.ts"),
1568                exports: vec![fallow_types::extract::ExportInfo {
1569                    name: ExportName::Named("orphan".to_string()),
1570                    local_name: Some("orphan".to_string()),
1571                    is_type_only: false,
1572                    span: oxc_span::Span::new(0, 20),
1573                    members: vec![],
1574                }],
1575                re_exports: vec![],
1576                resolved_imports: vec![],
1577                resolved_dynamic_imports: vec![],
1578                resolved_dynamic_patterns: vec![],
1579                member_accesses: vec![],
1580                whole_object_uses: vec![],
1581                has_cjs_exports: false,
1582            },
1583        ];
1584
1585        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1586
1587        assert!(graph.modules[0].is_reachable, "entry should be reachable");
1588        assert!(graph.modules[1].is_reachable, "utils should be reachable");
1589        assert!(
1590            !graph.modules[2].is_reachable,
1591            "orphan should NOT be reachable"
1592        );
1593    }
1594
1595    #[test]
1596    fn graph_package_usage_tracked() {
1597        let files = vec![DiscoveredFile {
1598            id: FileId(0),
1599            path: PathBuf::from("/project/entry.ts"),
1600            size_bytes: 100,
1601        }];
1602
1603        let entry_points = vec![EntryPoint {
1604            path: PathBuf::from("/project/entry.ts"),
1605            source: EntryPointSource::PackageJsonMain,
1606        }];
1607
1608        let resolved_modules = vec![ResolvedModule {
1609            file_id: FileId(0),
1610            path: PathBuf::from("/project/entry.ts"),
1611            exports: vec![],
1612            re_exports: vec![],
1613            resolved_imports: vec![
1614                ResolvedImport {
1615                    info: ImportInfo {
1616                        source: "react".to_string(),
1617                        imported_name: ImportedName::Default,
1618                        local_name: "React".to_string(),
1619                        is_type_only: false,
1620                        span: oxc_span::Span::new(0, 10),
1621                    },
1622                    target: ResolveResult::NpmPackage("react".to_string()),
1623                },
1624                ResolvedImport {
1625                    info: ImportInfo {
1626                        source: "lodash".to_string(),
1627                        imported_name: ImportedName::Named("merge".to_string()),
1628                        local_name: "merge".to_string(),
1629                        is_type_only: false,
1630                        span: oxc_span::Span::new(15, 30),
1631                    },
1632                    target: ResolveResult::NpmPackage("lodash".to_string()),
1633                },
1634            ],
1635            resolved_dynamic_imports: vec![],
1636            resolved_dynamic_patterns: vec![],
1637            member_accesses: vec![],
1638            whole_object_uses: vec![],
1639            has_cjs_exports: false,
1640        }];
1641
1642        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1643        assert!(graph.package_usage.contains_key("react"));
1644        assert!(graph.package_usage.contains_key("lodash"));
1645        assert!(!graph.package_usage.contains_key("express"));
1646    }
1647
1648    #[test]
1649    fn graph_re_export_chain_propagates_references() {
1650        // entry.ts -> barrel.ts -re-exports-> source.ts
1651        let files = vec![
1652            DiscoveredFile {
1653                id: FileId(0),
1654                path: PathBuf::from("/project/entry.ts"),
1655                size_bytes: 100,
1656            },
1657            DiscoveredFile {
1658                id: FileId(1),
1659                path: PathBuf::from("/project/barrel.ts"),
1660                size_bytes: 50,
1661            },
1662            DiscoveredFile {
1663                id: FileId(2),
1664                path: PathBuf::from("/project/source.ts"),
1665                size_bytes: 50,
1666            },
1667        ];
1668
1669        let entry_points = vec![EntryPoint {
1670            path: PathBuf::from("/project/entry.ts"),
1671            source: EntryPointSource::PackageJsonMain,
1672        }];
1673
1674        let resolved_modules = vec![
1675            // entry imports "foo" from barrel
1676            ResolvedModule {
1677                file_id: FileId(0),
1678                path: PathBuf::from("/project/entry.ts"),
1679                exports: vec![],
1680                re_exports: vec![],
1681                resolved_imports: vec![ResolvedImport {
1682                    info: ImportInfo {
1683                        source: "./barrel".to_string(),
1684                        imported_name: ImportedName::Named("foo".to_string()),
1685                        local_name: "foo".to_string(),
1686                        is_type_only: false,
1687                        span: oxc_span::Span::new(0, 10),
1688                    },
1689                    target: ResolveResult::InternalModule(FileId(1)),
1690                }],
1691                resolved_dynamic_imports: vec![],
1692                resolved_dynamic_patterns: vec![],
1693                member_accesses: vec![],
1694                whole_object_uses: vec![],
1695                has_cjs_exports: false,
1696            },
1697            // barrel re-exports "foo" from source
1698            ResolvedModule {
1699                file_id: FileId(1),
1700                path: PathBuf::from("/project/barrel.ts"),
1701                exports: vec![fallow_types::extract::ExportInfo {
1702                    name: ExportName::Named("foo".to_string()),
1703                    local_name: Some("foo".to_string()),
1704                    is_type_only: false,
1705                    span: oxc_span::Span::new(0, 20),
1706                    members: vec![],
1707                }],
1708                re_exports: vec![ResolvedReExport {
1709                    info: fallow_types::extract::ReExportInfo {
1710                        source: "./source".to_string(),
1711                        imported_name: "foo".to_string(),
1712                        exported_name: "foo".to_string(),
1713                        is_type_only: false,
1714                    },
1715                    target: ResolveResult::InternalModule(FileId(2)),
1716                }],
1717                resolved_imports: vec![],
1718                resolved_dynamic_imports: vec![],
1719                resolved_dynamic_patterns: vec![],
1720                member_accesses: vec![],
1721                whole_object_uses: vec![],
1722                has_cjs_exports: false,
1723            },
1724            // source has the actual export
1725            ResolvedModule {
1726                file_id: FileId(2),
1727                path: PathBuf::from("/project/source.ts"),
1728                exports: vec![fallow_types::extract::ExportInfo {
1729                    name: ExportName::Named("foo".to_string()),
1730                    local_name: Some("foo".to_string()),
1731                    is_type_only: false,
1732                    span: oxc_span::Span::new(0, 20),
1733                    members: vec![],
1734                }],
1735                re_exports: vec![],
1736                resolved_imports: vec![],
1737                resolved_dynamic_imports: vec![],
1738                resolved_dynamic_patterns: vec![],
1739                member_accesses: vec![],
1740                whole_object_uses: vec![],
1741                has_cjs_exports: false,
1742            },
1743        ];
1744
1745        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1746
1747        // The source module's "foo" export should have references propagated through the barrel
1748        let source_module = &graph.modules[2];
1749        let foo_export = source_module
1750            .exports
1751            .iter()
1752            .find(|e| e.name.to_string() == "foo")
1753            .unwrap();
1754        assert!(
1755            !foo_export.references.is_empty(),
1756            "source foo should have propagated references through barrel re-export chain"
1757        );
1758    }
1759
1760    #[test]
1761    fn graph_empty() {
1762        let graph = ModuleGraph::build(&[], &[], &[]);
1763        assert_eq!(graph.module_count(), 0);
1764        assert_eq!(graph.edge_count(), 0);
1765    }
1766
1767    #[test]
1768    fn graph_cjs_exports_tracked() {
1769        let files = vec![DiscoveredFile {
1770            id: FileId(0),
1771            path: PathBuf::from("/project/entry.ts"),
1772            size_bytes: 100,
1773        }];
1774
1775        let entry_points = vec![EntryPoint {
1776            path: PathBuf::from("/project/entry.ts"),
1777            source: EntryPointSource::PackageJsonMain,
1778        }];
1779
1780        let resolved_modules = vec![ResolvedModule {
1781            file_id: FileId(0),
1782            path: PathBuf::from("/project/entry.ts"),
1783            exports: vec![],
1784            re_exports: vec![],
1785            resolved_imports: vec![],
1786            resolved_dynamic_imports: vec![],
1787            resolved_dynamic_patterns: vec![],
1788            member_accesses: vec![],
1789            whole_object_uses: vec![],
1790            has_cjs_exports: true,
1791        }];
1792
1793        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1794        assert!(graph.modules[0].has_cjs_exports);
1795    }
1796
1797    #[test]
1798    fn barrel_re_export_creates_export_symbol() {
1799        let files = vec![
1800            DiscoveredFile {
1801                id: FileId(0),
1802                path: PathBuf::from("/project/entry.ts"),
1803                size_bytes: 100,
1804            },
1805            DiscoveredFile {
1806                id: FileId(1),
1807                path: PathBuf::from("/project/barrel.ts"),
1808                size_bytes: 50,
1809            },
1810            DiscoveredFile {
1811                id: FileId(2),
1812                path: PathBuf::from("/project/source.ts"),
1813                size_bytes: 50,
1814            },
1815        ];
1816
1817        let entry_points = vec![EntryPoint {
1818            path: PathBuf::from("/project/entry.ts"),
1819            source: EntryPointSource::PackageJsonMain,
1820        }];
1821
1822        let resolved_modules = vec![
1823            ResolvedModule {
1824                file_id: FileId(0),
1825                path: PathBuf::from("/project/entry.ts"),
1826                exports: vec![],
1827                re_exports: vec![],
1828                resolved_imports: vec![ResolvedImport {
1829                    info: ImportInfo {
1830                        source: "./barrel".to_string(),
1831                        imported_name: ImportedName::Named("foo".to_string()),
1832                        local_name: "foo".to_string(),
1833                        is_type_only: false,
1834                        span: oxc_span::Span::new(0, 10),
1835                    },
1836                    target: ResolveResult::InternalModule(FileId(1)),
1837                }],
1838                resolved_dynamic_imports: vec![],
1839                resolved_dynamic_patterns: vec![],
1840                member_accesses: vec![],
1841                whole_object_uses: vec![],
1842                has_cjs_exports: false,
1843            },
1844            ResolvedModule {
1845                file_id: FileId(1),
1846                path: PathBuf::from("/project/barrel.ts"),
1847                exports: vec![],
1848                re_exports: vec![ResolvedReExport {
1849                    info: fallow_types::extract::ReExportInfo {
1850                        source: "./source".to_string(),
1851                        imported_name: "foo".to_string(),
1852                        exported_name: "foo".to_string(),
1853                        is_type_only: false,
1854                    },
1855                    target: ResolveResult::InternalModule(FileId(2)),
1856                }],
1857                resolved_imports: vec![],
1858                resolved_dynamic_imports: vec![],
1859                resolved_dynamic_patterns: vec![],
1860                member_accesses: vec![],
1861                whole_object_uses: vec![],
1862                has_cjs_exports: false,
1863            },
1864            ResolvedModule {
1865                file_id: FileId(2),
1866                path: PathBuf::from("/project/source.ts"),
1867                exports: vec![fallow_types::extract::ExportInfo {
1868                    name: ExportName::Named("foo".to_string()),
1869                    local_name: Some("foo".to_string()),
1870                    is_type_only: false,
1871                    span: oxc_span::Span::new(0, 20),
1872                    members: vec![],
1873                }],
1874                re_exports: vec![],
1875                resolved_imports: vec![],
1876                resolved_dynamic_imports: vec![],
1877                resolved_dynamic_patterns: vec![],
1878                member_accesses: vec![],
1879                whole_object_uses: vec![],
1880                has_cjs_exports: false,
1881            },
1882        ];
1883
1884        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1885
1886        let barrel = &graph.modules[1];
1887        let foo_export = barrel.exports.iter().find(|e| e.name.to_string() == "foo");
1888        assert!(
1889            foo_export.is_some(),
1890            "barrel should have ExportSymbol for re-exported 'foo'"
1891        );
1892
1893        let foo = foo_export.unwrap();
1894        assert!(
1895            !foo.references.is_empty(),
1896            "barrel's foo should have a reference from entry.ts"
1897        );
1898
1899        let source = &graph.modules[2];
1900        let source_foo = source
1901            .exports
1902            .iter()
1903            .find(|e| e.name.to_string() == "foo")
1904            .unwrap();
1905        assert!(
1906            !source_foo.references.is_empty(),
1907            "source foo should have propagated references through barrel"
1908        );
1909    }
1910
1911    #[test]
1912    fn barrel_unused_re_export_has_no_references() {
1913        let files = vec![
1914            DiscoveredFile {
1915                id: FileId(0),
1916                path: PathBuf::from("/project/entry.ts"),
1917                size_bytes: 100,
1918            },
1919            DiscoveredFile {
1920                id: FileId(1),
1921                path: PathBuf::from("/project/barrel.ts"),
1922                size_bytes: 50,
1923            },
1924            DiscoveredFile {
1925                id: FileId(2),
1926                path: PathBuf::from("/project/source.ts"),
1927                size_bytes: 50,
1928            },
1929        ];
1930
1931        let entry_points = vec![EntryPoint {
1932            path: PathBuf::from("/project/entry.ts"),
1933            source: EntryPointSource::PackageJsonMain,
1934        }];
1935
1936        let resolved_modules = vec![
1937            ResolvedModule {
1938                file_id: FileId(0),
1939                path: PathBuf::from("/project/entry.ts"),
1940                exports: vec![],
1941                re_exports: vec![],
1942                resolved_imports: vec![ResolvedImport {
1943                    info: ImportInfo {
1944                        source: "./barrel".to_string(),
1945                        imported_name: ImportedName::Named("foo".to_string()),
1946                        local_name: "foo".to_string(),
1947                        is_type_only: false,
1948                        span: oxc_span::Span::new(0, 10),
1949                    },
1950                    target: ResolveResult::InternalModule(FileId(1)),
1951                }],
1952                resolved_dynamic_imports: vec![],
1953                resolved_dynamic_patterns: vec![],
1954                member_accesses: vec![],
1955                whole_object_uses: vec![],
1956                has_cjs_exports: false,
1957            },
1958            ResolvedModule {
1959                file_id: FileId(1),
1960                path: PathBuf::from("/project/barrel.ts"),
1961                exports: vec![],
1962                re_exports: vec![
1963                    ResolvedReExport {
1964                        info: fallow_types::extract::ReExportInfo {
1965                            source: "./source".to_string(),
1966                            imported_name: "foo".to_string(),
1967                            exported_name: "foo".to_string(),
1968                            is_type_only: false,
1969                        },
1970                        target: ResolveResult::InternalModule(FileId(2)),
1971                    },
1972                    ResolvedReExport {
1973                        info: fallow_types::extract::ReExportInfo {
1974                            source: "./source".to_string(),
1975                            imported_name: "bar".to_string(),
1976                            exported_name: "bar".to_string(),
1977                            is_type_only: false,
1978                        },
1979                        target: ResolveResult::InternalModule(FileId(2)),
1980                    },
1981                ],
1982                resolved_imports: vec![],
1983                resolved_dynamic_imports: vec![],
1984                resolved_dynamic_patterns: vec![],
1985                member_accesses: vec![],
1986                whole_object_uses: vec![],
1987                has_cjs_exports: false,
1988            },
1989            ResolvedModule {
1990                file_id: FileId(2),
1991                path: PathBuf::from("/project/source.ts"),
1992                exports: vec![
1993                    fallow_types::extract::ExportInfo {
1994                        name: ExportName::Named("foo".to_string()),
1995                        local_name: Some("foo".to_string()),
1996                        is_type_only: false,
1997                        span: oxc_span::Span::new(0, 20),
1998                        members: vec![],
1999                    },
2000                    fallow_types::extract::ExportInfo {
2001                        name: ExportName::Named("bar".to_string()),
2002                        local_name: Some("bar".to_string()),
2003                        is_type_only: false,
2004                        span: oxc_span::Span::new(25, 45),
2005                        members: vec![],
2006                    },
2007                ],
2008                re_exports: vec![],
2009                resolved_imports: vec![],
2010                resolved_dynamic_imports: vec![],
2011                resolved_dynamic_patterns: vec![],
2012                member_accesses: vec![],
2013                whole_object_uses: vec![],
2014                has_cjs_exports: false,
2015            },
2016        ];
2017
2018        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2019
2020        let barrel = &graph.modules[1];
2021        let foo = barrel
2022            .exports
2023            .iter()
2024            .find(|e| e.name.to_string() == "foo")
2025            .unwrap();
2026        assert!(!foo.references.is_empty(), "barrel's foo should be used");
2027
2028        let bar = barrel
2029            .exports
2030            .iter()
2031            .find(|e| e.name.to_string() == "bar")
2032            .unwrap();
2033        assert!(
2034            bar.references.is_empty(),
2035            "barrel's bar should be unused (no consumer imports it)"
2036        );
2037    }
2038
2039    #[test]
2040    fn type_only_re_export_creates_type_only_export_symbol() {
2041        let files = vec![
2042            DiscoveredFile {
2043                id: FileId(0),
2044                path: PathBuf::from("/project/entry.ts"),
2045                size_bytes: 100,
2046            },
2047            DiscoveredFile {
2048                id: FileId(1),
2049                path: PathBuf::from("/project/barrel.ts"),
2050                size_bytes: 50,
2051            },
2052            DiscoveredFile {
2053                id: FileId(2),
2054                path: PathBuf::from("/project/source.ts"),
2055                size_bytes: 50,
2056            },
2057        ];
2058
2059        let entry_points = vec![EntryPoint {
2060            path: PathBuf::from("/project/entry.ts"),
2061            source: EntryPointSource::PackageJsonMain,
2062        }];
2063
2064        let resolved_modules = vec![
2065            ResolvedModule {
2066                file_id: FileId(0),
2067                path: PathBuf::from("/project/entry.ts"),
2068                exports: vec![],
2069                re_exports: vec![],
2070                resolved_imports: vec![ResolvedImport {
2071                    info: ImportInfo {
2072                        source: "./barrel".to_string(),
2073                        imported_name: ImportedName::Named("UsedType".to_string()),
2074                        local_name: "UsedType".to_string(),
2075                        is_type_only: true,
2076                        span: oxc_span::Span::new(0, 10),
2077                    },
2078                    target: ResolveResult::InternalModule(FileId(1)),
2079                }],
2080                resolved_dynamic_imports: vec![],
2081                resolved_dynamic_patterns: vec![],
2082                member_accesses: vec![],
2083                whole_object_uses: vec![],
2084                has_cjs_exports: false,
2085            },
2086            ResolvedModule {
2087                file_id: FileId(1),
2088                path: PathBuf::from("/project/barrel.ts"),
2089                exports: vec![],
2090                re_exports: vec![
2091                    ResolvedReExport {
2092                        info: fallow_types::extract::ReExportInfo {
2093                            source: "./source".to_string(),
2094                            imported_name: "UsedType".to_string(),
2095                            exported_name: "UsedType".to_string(),
2096                            is_type_only: true,
2097                        },
2098                        target: ResolveResult::InternalModule(FileId(2)),
2099                    },
2100                    ResolvedReExport {
2101                        info: fallow_types::extract::ReExportInfo {
2102                            source: "./source".to_string(),
2103                            imported_name: "UnusedType".to_string(),
2104                            exported_name: "UnusedType".to_string(),
2105                            is_type_only: true,
2106                        },
2107                        target: ResolveResult::InternalModule(FileId(2)),
2108                    },
2109                ],
2110                resolved_imports: vec![],
2111                resolved_dynamic_imports: vec![],
2112                resolved_dynamic_patterns: vec![],
2113                member_accesses: vec![],
2114                whole_object_uses: vec![],
2115                has_cjs_exports: false,
2116            },
2117            ResolvedModule {
2118                file_id: FileId(2),
2119                path: PathBuf::from("/project/source.ts"),
2120                exports: vec![
2121                    fallow_types::extract::ExportInfo {
2122                        name: ExportName::Named("UsedType".to_string()),
2123                        local_name: Some("UsedType".to_string()),
2124                        is_type_only: true,
2125                        span: oxc_span::Span::new(0, 20),
2126                        members: vec![],
2127                    },
2128                    fallow_types::extract::ExportInfo {
2129                        name: ExportName::Named("UnusedType".to_string()),
2130                        local_name: Some("UnusedType".to_string()),
2131                        is_type_only: true,
2132                        span: oxc_span::Span::new(25, 45),
2133                        members: vec![],
2134                    },
2135                ],
2136                re_exports: vec![],
2137                resolved_imports: vec![],
2138                resolved_dynamic_imports: vec![],
2139                resolved_dynamic_patterns: vec![],
2140                member_accesses: vec![],
2141                whole_object_uses: vec![],
2142                has_cjs_exports: false,
2143            },
2144        ];
2145
2146        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2147
2148        let barrel = &graph.modules[1];
2149
2150        let used_type = barrel
2151            .exports
2152            .iter()
2153            .find(|e| e.name.to_string() == "UsedType")
2154            .expect("barrel should have ExportSymbol for UsedType");
2155        assert!(used_type.is_type_only, "UsedType should be type-only");
2156        assert!(
2157            !used_type.references.is_empty(),
2158            "UsedType should have references"
2159        );
2160
2161        let unused_type = barrel
2162            .exports
2163            .iter()
2164            .find(|e| e.name.to_string() == "UnusedType")
2165            .expect("barrel should have ExportSymbol for UnusedType");
2166        assert!(unused_type.is_type_only, "UnusedType should be type-only");
2167        assert!(
2168            unused_type.references.is_empty(),
2169            "UnusedType should have no references"
2170        );
2171    }
2172
2173    #[test]
2174    fn default_re_export_creates_default_export_symbol() {
2175        let files = vec![
2176            DiscoveredFile {
2177                id: FileId(0),
2178                path: PathBuf::from("/project/entry.ts"),
2179                size_bytes: 100,
2180            },
2181            DiscoveredFile {
2182                id: FileId(1),
2183                path: PathBuf::from("/project/barrel.ts"),
2184                size_bytes: 50,
2185            },
2186            DiscoveredFile {
2187                id: FileId(2),
2188                path: PathBuf::from("/project/source.ts"),
2189                size_bytes: 50,
2190            },
2191        ];
2192
2193        let entry_points = vec![EntryPoint {
2194            path: PathBuf::from("/project/entry.ts"),
2195            source: EntryPointSource::PackageJsonMain,
2196        }];
2197
2198        let resolved_modules = vec![
2199            ResolvedModule {
2200                file_id: FileId(0),
2201                path: PathBuf::from("/project/entry.ts"),
2202                exports: vec![],
2203                re_exports: vec![],
2204                resolved_imports: vec![ResolvedImport {
2205                    info: ImportInfo {
2206                        source: "./barrel".to_string(),
2207                        imported_name: ImportedName::Named("Accordion".to_string()),
2208                        local_name: "Accordion".to_string(),
2209                        is_type_only: false,
2210                        span: oxc_span::Span::new(0, 10),
2211                    },
2212                    target: ResolveResult::InternalModule(FileId(1)),
2213                }],
2214                resolved_dynamic_imports: vec![],
2215                resolved_dynamic_patterns: vec![],
2216                member_accesses: vec![],
2217                whole_object_uses: vec![],
2218                has_cjs_exports: false,
2219            },
2220            ResolvedModule {
2221                file_id: FileId(1),
2222                path: PathBuf::from("/project/barrel.ts"),
2223                exports: vec![],
2224                re_exports: vec![ResolvedReExport {
2225                    info: fallow_types::extract::ReExportInfo {
2226                        source: "./source".to_string(),
2227                        imported_name: "default".to_string(),
2228                        exported_name: "Accordion".to_string(),
2229                        is_type_only: false,
2230                    },
2231                    target: ResolveResult::InternalModule(FileId(2)),
2232                }],
2233                resolved_imports: vec![],
2234                resolved_dynamic_imports: vec![],
2235                resolved_dynamic_patterns: vec![],
2236                member_accesses: vec![],
2237                whole_object_uses: vec![],
2238                has_cjs_exports: false,
2239            },
2240            ResolvedModule {
2241                file_id: FileId(2),
2242                path: PathBuf::from("/project/source.ts"),
2243                exports: vec![fallow_types::extract::ExportInfo {
2244                    name: ExportName::Default,
2245                    local_name: None,
2246                    is_type_only: false,
2247                    span: oxc_span::Span::new(0, 20),
2248                    members: vec![],
2249                }],
2250                re_exports: vec![],
2251                resolved_imports: vec![],
2252                resolved_dynamic_imports: vec![],
2253                resolved_dynamic_patterns: vec![],
2254                member_accesses: vec![],
2255                whole_object_uses: vec![],
2256                has_cjs_exports: false,
2257            },
2258        ];
2259
2260        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2261
2262        let barrel = &graph.modules[1];
2263        let accordion = barrel
2264            .exports
2265            .iter()
2266            .find(|e| e.name.to_string() == "Accordion")
2267            .expect("barrel should have ExportSymbol for Accordion");
2268        assert!(
2269            !accordion.references.is_empty(),
2270            "Accordion should have reference from entry.ts"
2271        );
2272
2273        let source = &graph.modules[2];
2274        let default_export = source
2275            .exports
2276            .iter()
2277            .find(|e| matches!(e.name, ExportName::Default))
2278            .unwrap();
2279        assert!(
2280            !default_export.references.is_empty(),
2281            "source default export should have propagated references"
2282        );
2283    }
2284
2285    #[test]
2286    fn multi_level_re_export_chain_propagation() {
2287        let files = vec![
2288            DiscoveredFile {
2289                id: FileId(0),
2290                path: PathBuf::from("/project/entry.ts"),
2291                size_bytes: 100,
2292            },
2293            DiscoveredFile {
2294                id: FileId(1),
2295                path: PathBuf::from("/project/barrel1.ts"),
2296                size_bytes: 50,
2297            },
2298            DiscoveredFile {
2299                id: FileId(2),
2300                path: PathBuf::from("/project/barrel2.ts"),
2301                size_bytes: 50,
2302            },
2303            DiscoveredFile {
2304                id: FileId(3),
2305                path: PathBuf::from("/project/source.ts"),
2306                size_bytes: 50,
2307            },
2308        ];
2309
2310        let entry_points = vec![EntryPoint {
2311            path: PathBuf::from("/project/entry.ts"),
2312            source: EntryPointSource::PackageJsonMain,
2313        }];
2314
2315        let resolved_modules = vec![
2316            ResolvedModule {
2317                file_id: FileId(0),
2318                path: PathBuf::from("/project/entry.ts"),
2319                exports: vec![],
2320                re_exports: vec![],
2321                resolved_imports: vec![ResolvedImport {
2322                    info: ImportInfo {
2323                        source: "./barrel1".to_string(),
2324                        imported_name: ImportedName::Named("foo".to_string()),
2325                        local_name: "foo".to_string(),
2326                        is_type_only: false,
2327                        span: oxc_span::Span::new(0, 10),
2328                    },
2329                    target: ResolveResult::InternalModule(FileId(1)),
2330                }],
2331                resolved_dynamic_imports: vec![],
2332                resolved_dynamic_patterns: vec![],
2333                member_accesses: vec![],
2334                whole_object_uses: vec![],
2335                has_cjs_exports: false,
2336            },
2337            ResolvedModule {
2338                file_id: FileId(1),
2339                path: PathBuf::from("/project/barrel1.ts"),
2340                exports: vec![],
2341                re_exports: vec![ResolvedReExport {
2342                    info: fallow_types::extract::ReExportInfo {
2343                        source: "./barrel2".to_string(),
2344                        imported_name: "foo".to_string(),
2345                        exported_name: "foo".to_string(),
2346                        is_type_only: false,
2347                    },
2348                    target: ResolveResult::InternalModule(FileId(2)),
2349                }],
2350                resolved_imports: vec![],
2351                resolved_dynamic_imports: vec![],
2352                resolved_dynamic_patterns: vec![],
2353                member_accesses: vec![],
2354                whole_object_uses: vec![],
2355                has_cjs_exports: false,
2356            },
2357            ResolvedModule {
2358                file_id: FileId(2),
2359                path: PathBuf::from("/project/barrel2.ts"),
2360                exports: vec![],
2361                re_exports: vec![ResolvedReExport {
2362                    info: fallow_types::extract::ReExportInfo {
2363                        source: "./source".to_string(),
2364                        imported_name: "foo".to_string(),
2365                        exported_name: "foo".to_string(),
2366                        is_type_only: false,
2367                    },
2368                    target: ResolveResult::InternalModule(FileId(3)),
2369                }],
2370                resolved_imports: vec![],
2371                resolved_dynamic_imports: vec![],
2372                resolved_dynamic_patterns: vec![],
2373                member_accesses: vec![],
2374                whole_object_uses: vec![],
2375                has_cjs_exports: false,
2376            },
2377            ResolvedModule {
2378                file_id: FileId(3),
2379                path: PathBuf::from("/project/source.ts"),
2380                exports: vec![fallow_types::extract::ExportInfo {
2381                    name: ExportName::Named("foo".to_string()),
2382                    local_name: Some("foo".to_string()),
2383                    is_type_only: false,
2384                    span: oxc_span::Span::new(0, 20),
2385                    members: vec![],
2386                }],
2387                re_exports: vec![],
2388                resolved_imports: vec![],
2389                resolved_dynamic_imports: vec![],
2390                resolved_dynamic_patterns: vec![],
2391                member_accesses: vec![],
2392                whole_object_uses: vec![],
2393                has_cjs_exports: false,
2394            },
2395        ];
2396
2397        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
2398
2399        let barrel1 = &graph.modules[1];
2400        let b1_foo = barrel1
2401            .exports
2402            .iter()
2403            .find(|e| e.name.to_string() == "foo")
2404            .unwrap();
2405        assert!(
2406            !b1_foo.references.is_empty(),
2407            "barrel1's foo should be referenced"
2408        );
2409
2410        let barrel2 = &graph.modules[2];
2411        let b2_foo = barrel2
2412            .exports
2413            .iter()
2414            .find(|e| e.name.to_string() == "foo")
2415            .unwrap();
2416        assert!(
2417            !b2_foo.references.is_empty(),
2418            "barrel2's foo should be referenced (propagated through chain)"
2419        );
2420
2421        let source = &graph.modules[3];
2422        let src_foo = source
2423            .exports
2424            .iter()
2425            .find(|e| e.name.to_string() == "foo")
2426            .unwrap();
2427        assert!(
2428            !src_foo.references.is_empty(),
2429            "source's foo should be referenced (propagated through 2-level chain)"
2430        );
2431    }
2432
2433    // ---- Cycle detection tests ----
2434
2435    /// Helper: build a graph from files+edges, no entry points needed for cycle detection.
2436    fn build_cycle_graph(file_count: usize, edges_spec: &[(u32, u32)]) -> ModuleGraph {
2437        let files: Vec<DiscoveredFile> = (0..file_count)
2438            .map(|i| DiscoveredFile {
2439                id: FileId(i as u32),
2440                path: PathBuf::from(format!("/project/file{i}.ts")),
2441                size_bytes: 100,
2442            })
2443            .collect();
2444
2445        let resolved_modules: Vec<ResolvedModule> = (0..file_count)
2446            .map(|i| {
2447                let imports: Vec<ResolvedImport> = edges_spec
2448                    .iter()
2449                    .filter(|(src, _)| *src == i as u32)
2450                    .map(|(_, tgt)| ResolvedImport {
2451                        info: ImportInfo {
2452                            source: format!("./file{tgt}"),
2453                            imported_name: ImportedName::Named("x".to_string()),
2454                            local_name: "x".to_string(),
2455                            is_type_only: false,
2456                            span: oxc_span::Span::new(0, 10),
2457                        },
2458                        target: ResolveResult::InternalModule(FileId(*tgt)),
2459                    })
2460                    .collect();
2461
2462                ResolvedModule {
2463                    file_id: FileId(i as u32),
2464                    path: PathBuf::from(format!("/project/file{i}.ts")),
2465                    exports: vec![fallow_types::extract::ExportInfo {
2466                        name: ExportName::Named("x".to_string()),
2467                        local_name: Some("x".to_string()),
2468                        is_type_only: false,
2469                        span: oxc_span::Span::new(0, 20),
2470                        members: vec![],
2471                    }],
2472                    re_exports: vec![],
2473                    resolved_imports: imports,
2474                    resolved_dynamic_imports: vec![],
2475                    resolved_dynamic_patterns: vec![],
2476                    member_accesses: vec![],
2477                    whole_object_uses: vec![],
2478                    has_cjs_exports: false,
2479                }
2480            })
2481            .collect();
2482
2483        let entry_points = vec![EntryPoint {
2484            path: PathBuf::from("/project/file0.ts"),
2485            source: EntryPointSource::PackageJsonMain,
2486        }];
2487
2488        ModuleGraph::build(&resolved_modules, &entry_points, &files)
2489    }
2490
2491    #[test]
2492    fn find_cycles_empty_graph() {
2493        let graph = ModuleGraph::build(&[], &[], &[]);
2494        assert!(graph.find_cycles().is_empty());
2495    }
2496
2497    #[test]
2498    fn find_cycles_no_cycles() {
2499        // A -> B -> C (no back edges)
2500        let graph = build_cycle_graph(3, &[(0, 1), (1, 2)]);
2501        assert!(graph.find_cycles().is_empty());
2502    }
2503
2504    #[test]
2505    fn find_cycles_simple_two_node_cycle() {
2506        // A -> B -> A
2507        let graph = build_cycle_graph(2, &[(0, 1), (1, 0)]);
2508        let cycles = graph.find_cycles();
2509        assert_eq!(cycles.len(), 1);
2510        assert_eq!(cycles[0].len(), 2);
2511    }
2512
2513    #[test]
2514    fn find_cycles_three_node_cycle() {
2515        // A -> B -> C -> A
2516        let graph = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
2517        let cycles = graph.find_cycles();
2518        assert_eq!(cycles.len(), 1);
2519        assert_eq!(cycles[0].len(), 3);
2520    }
2521
2522    #[test]
2523    fn find_cycles_self_import_ignored() {
2524        // A -> A (self-import, should NOT be reported)
2525        let graph = build_cycle_graph(1, &[(0, 0)]);
2526        let cycles = graph.find_cycles();
2527        assert!(
2528            cycles.is_empty(),
2529            "self-imports should not be reported as cycles"
2530        );
2531    }
2532
2533    #[test]
2534    fn find_cycles_multiple_independent_cycles() {
2535        // Cycle 1: A -> B -> A
2536        // Cycle 2: C -> D -> C
2537        // No connection between cycles
2538        let graph = build_cycle_graph(4, &[(0, 1), (1, 0), (2, 3), (3, 2)]);
2539        let cycles = graph.find_cycles();
2540        assert_eq!(cycles.len(), 2);
2541        // Both cycles should have length 2
2542        assert!(cycles.iter().all(|c| c.len() == 2));
2543    }
2544
2545    #[test]
2546    fn find_cycles_linear_chain_with_back_edge() {
2547        // A -> B -> C -> D -> B (cycle is B-C-D)
2548        let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 3), (3, 1)]);
2549        let cycles = graph.find_cycles();
2550        assert_eq!(cycles.len(), 1);
2551        assert_eq!(cycles[0].len(), 3);
2552        // The cycle should contain files 1, 2, 3
2553        let ids: Vec<u32> = cycles[0].iter().map(|f| f.0).collect();
2554        assert!(ids.contains(&1));
2555        assert!(ids.contains(&2));
2556        assert!(ids.contains(&3));
2557        assert!(!ids.contains(&0));
2558    }
2559
2560    #[test]
2561    fn find_cycles_overlapping_cycles_enumerated() {
2562        // A -> B -> A, B -> C -> B => SCC is {A, B, C} but should report 2 elementary cycles
2563        let graph = build_cycle_graph(3, &[(0, 1), (1, 0), (1, 2), (2, 1)]);
2564        let cycles = graph.find_cycles();
2565        assert_eq!(
2566            cycles.len(),
2567            2,
2568            "should find 2 elementary cycles, not 1 SCC"
2569        );
2570        assert!(
2571            cycles.iter().all(|c| c.len() == 2),
2572            "both cycles should have length 2"
2573        );
2574    }
2575
2576    #[test]
2577    fn find_cycles_deterministic_ordering() {
2578        // Run twice with the same graph — results should be identical
2579        let graph1 = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
2580        let graph2 = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
2581        let cycles1 = graph1.find_cycles();
2582        let cycles2 = graph2.find_cycles();
2583        assert_eq!(cycles1.len(), cycles2.len());
2584        for (c1, c2) in cycles1.iter().zip(cycles2.iter()) {
2585            let paths1: Vec<&PathBuf> = c1
2586                .iter()
2587                .map(|f| &graph1.modules[f.0 as usize].path)
2588                .collect();
2589            let paths2: Vec<&PathBuf> = c2
2590                .iter()
2591                .map(|f| &graph2.modules[f.0 as usize].path)
2592                .collect();
2593            assert_eq!(paths1, paths2);
2594        }
2595    }
2596
2597    #[test]
2598    fn find_cycles_sorted_by_length() {
2599        // Two cycles: A-B (len 2) and C-D-E (len 3)
2600        let graph = build_cycle_graph(5, &[(0, 1), (1, 0), (2, 3), (3, 4), (4, 2)]);
2601        let cycles = graph.find_cycles();
2602        assert_eq!(cycles.len(), 2);
2603        assert!(
2604            cycles[0].len() <= cycles[1].len(),
2605            "cycles should be sorted by length"
2606        );
2607    }
2608
2609    #[test]
2610    fn find_cycles_large_cycle() {
2611        // Chain of 10 nodes forming a single cycle: 0->1->2->...->9->0
2612        let edges: Vec<(u32, u32)> = (0..10).map(|i| (i, (i + 1) % 10)).collect();
2613        let graph = build_cycle_graph(10, &edges);
2614        let cycles = graph.find_cycles();
2615        assert_eq!(cycles.len(), 1);
2616        assert_eq!(cycles[0].len(), 10);
2617    }
2618
2619    #[test]
2620    fn find_cycles_complex_scc_multiple_elementary() {
2621        // A square: A->B, B->C, C->D, D->A, plus diagonal A->C
2622        // Elementary cycles: A->B->C->D->A, A->C->D->A, and A->B->C->...
2623        let graph = build_cycle_graph(4, &[(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)]);
2624        let cycles = graph.find_cycles();
2625        // Should find multiple elementary cycles, not just one SCC of 4
2626        assert!(
2627            cycles.len() >= 2,
2628            "should find at least 2 elementary cycles, got {}",
2629            cycles.len()
2630        );
2631        // All cycles should be shorter than the full SCC
2632        assert!(cycles.iter().all(|c| c.len() <= 4));
2633    }
2634
2635    #[test]
2636    fn find_cycles_no_duplicate_cycles() {
2637        // Triangle: A->B->C->A — should find exactly 1 cycle, not duplicates
2638        // from different DFS start points
2639        let graph = build_cycle_graph(3, &[(0, 1), (1, 2), (2, 0)]);
2640        let cycles = graph.find_cycles();
2641        assert_eq!(cycles.len(), 1, "triangle should produce exactly 1 cycle");
2642        assert_eq!(cycles[0].len(), 3);
2643    }
2644}