Skip to main content

fallow_core/
graph.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2use std::ops::Range;
3use std::path::PathBuf;
4
5use fixedbitset::FixedBitSet;
6
7use crate::discover::{DiscoveredFile, EntryPoint, FileId};
8use crate::extract::{ExportName, ImportedName};
9use crate::resolve::{ResolveResult, ResolvedModule};
10
11/// The core module dependency graph.
12#[derive(Debug)]
13pub struct ModuleGraph {
14    /// All modules indexed by FileId.
15    pub modules: Vec<ModuleNode>,
16    /// Flat edge storage for cache-friendly iteration.
17    edges: Vec<Edge>,
18    /// Maps npm package names to the set of FileIds that import them.
19    pub package_usage: HashMap<String, Vec<FileId>>,
20    /// All entry point FileIds.
21    pub entry_points: HashSet<FileId>,
22    /// Reverse index: for each FileId, which files import it.
23    pub reverse_deps: Vec<Vec<FileId>>,
24    /// Precomputed: which modules have namespace imports (import * as ns).
25    namespace_imported: FixedBitSet,
26}
27
28/// A single module in the graph.
29#[derive(Debug)]
30pub struct ModuleNode {
31    pub file_id: FileId,
32    pub path: PathBuf,
33    /// Range into the flat `edges` array.
34    pub edge_range: Range<usize>,
35    /// Exports declared by this module.
36    pub exports: Vec<ExportSymbol>,
37    /// Re-exports from this module (export { x } from './y', export * from './z').
38    pub re_exports: Vec<ReExportEdge>,
39    /// Whether this module is an entry point.
40    pub is_entry_point: bool,
41    /// Whether this module is reachable from any entry point.
42    pub is_reachable: bool,
43    /// Whether this module has CJS exports (module.exports / exports.*).
44    pub has_cjs_exports: bool,
45}
46
47/// A re-export edge, tracking which exports are forwarded from which module.
48#[derive(Debug)]
49pub struct ReExportEdge {
50    /// The module being re-exported from.
51    pub source_file: FileId,
52    /// The name imported from the source (or "*" for star re-exports).
53    pub imported_name: String,
54    /// The name exported from this module.
55    pub exported_name: String,
56    /// Whether this is a type-only re-export.
57    pub is_type_only: bool,
58}
59
60/// An export with reference tracking.
61#[derive(Debug)]
62pub struct ExportSymbol {
63    pub name: ExportName,
64    pub is_type_only: bool,
65    pub span: oxc_span::Span,
66    /// Which files reference this export.
67    pub references: Vec<SymbolReference>,
68    /// Members of this export (enum members, class members).
69    pub members: Vec<crate::extract::MemberInfo>,
70}
71
72/// A reference to an export from another file.
73#[derive(Debug, Clone)]
74pub struct SymbolReference {
75    pub from_file: FileId,
76    pub kind: ReferenceKind,
77}
78
79/// How an export is referenced.
80#[derive(Debug, Clone, PartialEq)]
81pub enum ReferenceKind {
82    NamedImport,
83    DefaultImport,
84    NamespaceImport,
85    ReExport,
86    DynamicImport,
87    SideEffectImport,
88}
89
90/// An edge in the module graph.
91#[derive(Debug)]
92#[allow(dead_code)]
93struct Edge {
94    source: FileId,
95    target: FileId,
96    symbols: Vec<ImportedSymbol>,
97    is_dynamic: bool,
98    is_side_effect: bool,
99}
100
101/// A symbol imported across an edge.
102#[derive(Debug)]
103struct ImportedSymbol {
104    imported_name: ImportedName,
105    #[allow(dead_code)]
106    local_name: String,
107}
108
109impl ModuleGraph {
110    /// Build the module graph from resolved modules and entry points.
111    pub fn build(
112        resolved_modules: &[ResolvedModule],
113        entry_points: &[EntryPoint],
114        files: &[DiscoveredFile],
115    ) -> Self {
116        let _span = tracing::info_span!("build_graph").entered();
117
118        let module_count = files.len();
119
120        // Compute the total capacity needed, accounting for workspace FileIds
121        // that may exceed files.len() if IDs are assigned beyond the file count.
122        let max_file_id = files
123            .iter()
124            .map(|f| f.id.0 as usize)
125            .max()
126            .map(|m| m + 1)
127            .unwrap_or(0);
128        let total_capacity = max_file_id.max(module_count);
129
130        // Build path -> FileId index
131        let path_to_id: HashMap<PathBuf, FileId> =
132            files.iter().map(|f| (f.path.clone(), f.id)).collect();
133
134        // Build FileId -> ResolvedModule index
135        let module_by_id: HashMap<FileId, &ResolvedModule> =
136            resolved_modules.iter().map(|m| (m.file_id, m)).collect();
137
138        let mut all_edges = Vec::new();
139        let mut modules = Vec::with_capacity(module_count);
140        let mut package_usage: HashMap<String, Vec<FileId>> = HashMap::new();
141        let mut reverse_deps = vec![Vec::new(); total_capacity];
142
143        // Build entry point set — use path_to_id map instead of O(n) scan per entry
144        let entry_point_ids: HashSet<FileId> = entry_points
145            .iter()
146            .filter_map(|ep| {
147                // Try direct lookup first (fast path)
148                path_to_id.get(&ep.path).copied().or_else(|| {
149                    // Fallback: canonicalize entry point and match
150                    ep.path.canonicalize().ok().and_then(|c| {
151                        path_to_id
152                            .iter()
153                            .find(|(p, _)| p.canonicalize().ok().as_ref() == Some(&c))
154                            .map(|(_, &id)| id)
155                    })
156                })
157            })
158            .collect();
159
160        // Track which modules have namespace imports (precomputed)
161        let mut namespace_imported = FixedBitSet::with_capacity(total_capacity);
162
163        for file in files {
164            let edge_start = all_edges.len();
165
166            if let Some(resolved) = module_by_id.get(&file.id) {
167                // Group imports by target
168                let mut edges_by_target: HashMap<FileId, Vec<ImportedSymbol>> = HashMap::new();
169
170                for import in &resolved.resolved_imports {
171                    match &import.target {
172                        ResolveResult::InternalModule(target_id) => {
173                            // Track namespace imports during edge creation
174                            if matches!(import.info.imported_name, ImportedName::Namespace) {
175                                let idx = target_id.0 as usize;
176                                if idx < total_capacity {
177                                    namespace_imported.insert(idx);
178                                }
179                            }
180                            edges_by_target
181                                .entry(*target_id)
182                                .or_default()
183                                .push(ImportedSymbol {
184                                    imported_name: import.info.imported_name.clone(),
185                                    local_name: import.info.local_name.clone(),
186                                });
187                        }
188                        ResolveResult::NpmPackage(name) => {
189                            package_usage.entry(name.clone()).or_default().push(file.id);
190                        }
191                        _ => {}
192                    }
193                }
194
195                // Re-exports also create edges
196                for re_export in &resolved.re_exports {
197                    if let ResolveResult::InternalModule(target_id) = &re_export.target {
198                        let imp_name = if re_export.info.imported_name == "*" {
199                            ImportedName::Namespace
200                        } else {
201                            ImportedName::Named(re_export.info.imported_name.clone())
202                        };
203                        // Track namespace re-exports
204                        if matches!(imp_name, ImportedName::Namespace) {
205                            let idx = target_id.0 as usize;
206                            if idx < total_capacity {
207                                namespace_imported.insert(idx);
208                            }
209                        }
210                        edges_by_target
211                            .entry(*target_id)
212                            .or_default()
213                            .push(ImportedSymbol {
214                                imported_name: imp_name,
215                                local_name: re_export.info.exported_name.clone(),
216                            });
217                    } else if let ResolveResult::NpmPackage(name) = &re_export.target {
218                        package_usage.entry(name.clone()).or_default().push(file.id);
219                    }
220                }
221
222                // Dynamic imports - treat as namespace import since caller can access any export
223                for import in &resolved.resolved_dynamic_imports {
224                    if let ResolveResult::InternalModule(target_id) = &import.target {
225                        let idx = target_id.0 as usize;
226                        if idx < total_capacity {
227                            namespace_imported.insert(idx);
228                        }
229                        edges_by_target
230                            .entry(*target_id)
231                            .or_default()
232                            .push(ImportedSymbol {
233                                imported_name: ImportedName::Namespace,
234                                local_name: String::new(),
235                            });
236                    }
237                }
238
239                // Dynamic import patterns (template literals, string concat, import.meta.glob)
240                for (_pattern, matched_ids) in &resolved.resolved_dynamic_patterns {
241                    for target_id in matched_ids {
242                        let idx = target_id.0 as usize;
243                        if idx < total_capacity {
244                            namespace_imported.insert(idx);
245                        }
246                        edges_by_target
247                            .entry(*target_id)
248                            .or_default()
249                            .push(ImportedSymbol {
250                                imported_name: ImportedName::Namespace,
251                                local_name: String::new(),
252                            });
253                    }
254                }
255
256                for (target_id, symbols) in edges_by_target {
257                    let is_side_effect = symbols
258                        .iter()
259                        .any(|s| matches!(s.imported_name, ImportedName::SideEffect));
260
261                    all_edges.push(Edge {
262                        source: file.id,
263                        target: target_id,
264                        symbols,
265                        is_dynamic: false,
266                        is_side_effect,
267                    });
268
269                    if (target_id.0 as usize) < reverse_deps.len() {
270                        reverse_deps[target_id.0 as usize].push(file.id);
271                    }
272                }
273            }
274
275            let edge_end = all_edges.len();
276
277            let mut exports: Vec<ExportSymbol> = module_by_id
278                .get(&file.id)
279                .map(|m| {
280                    m.exports
281                        .iter()
282                        .map(|e| ExportSymbol {
283                            name: e.name.clone(),
284                            is_type_only: e.is_type_only,
285                            span: e.span,
286                            references: Vec::new(),
287                            members: e.members.clone(),
288                        })
289                        .collect()
290                })
291                .unwrap_or_default();
292
293            // Create ExportSymbol entries for re-exports so that consumers
294            // importing from this barrel can have their references attached.
295            // Without this, `export { Foo } from './source'` on a barrel would
296            // not be trackable as an export of the barrel module.
297            if let Some(resolved) = module_by_id.get(&file.id) {
298                for re in &resolved.re_exports {
299                    // Skip star re-exports without an alias (`export * from './x'`)
300                    // — they don't create a named export on the barrel.
301                    // But `export * as name from './x'` does create one.
302                    if re.info.exported_name == "*" {
303                        continue;
304                    }
305
306                    // Avoid duplicates: if an export with this name already exists
307                    // (e.g. the module both declares and re-exports the same name),
308                    // skip creating another one.
309                    let export_name = if re.info.exported_name == "default" {
310                        ExportName::Default
311                    } else {
312                        ExportName::Named(re.info.exported_name.clone())
313                    };
314                    let already_exists = exports.iter().any(|e| e.name == export_name);
315                    if already_exists {
316                        continue;
317                    }
318
319                    exports.push(ExportSymbol {
320                        name: export_name,
321                        is_type_only: re.info.is_type_only,
322                        span: oxc_span::Span::new(0, 0), // re-exports don't have a meaningful span on the barrel
323                        references: Vec::new(),
324                        members: Vec::new(),
325                    });
326                }
327            }
328
329            let has_cjs_exports = module_by_id
330                .get(&file.id)
331                .map(|m| m.has_cjs_exports)
332                .unwrap_or(false);
333
334            // Build re-export edges
335            let re_export_edges: Vec<ReExportEdge> = module_by_id
336                .get(&file.id)
337                .map(|m| {
338                    m.re_exports
339                        .iter()
340                        .filter_map(|re| {
341                            if let ResolveResult::InternalModule(target_id) = &re.target {
342                                Some(ReExportEdge {
343                                    source_file: *target_id,
344                                    imported_name: re.info.imported_name.clone(),
345                                    exported_name: re.info.exported_name.clone(),
346                                    is_type_only: re.info.is_type_only,
347                                })
348                            } else {
349                                None
350                            }
351                        })
352                        .collect()
353                })
354                .unwrap_or_default();
355
356            modules.push(ModuleNode {
357                file_id: file.id,
358                path: file.path.clone(),
359                edge_range: edge_start..edge_end,
360                exports,
361                re_exports: re_export_edges,
362                is_entry_point: entry_point_ids.contains(&file.id),
363                is_reachable: false,
364                has_cjs_exports,
365            });
366        }
367
368        // Populate export references from edges — O(edges) not O(edges × modules)
369        for edge in &all_edges {
370            let source_id = edge.source;
371            let Some(target_module) = modules.get_mut(edge.target.0 as usize) else {
372                continue;
373            };
374            for sym in &edge.symbols {
375                let ref_kind = match &sym.imported_name {
376                    ImportedName::Named(_) => ReferenceKind::NamedImport,
377                    ImportedName::Default => ReferenceKind::DefaultImport,
378                    ImportedName::Namespace => ReferenceKind::NamespaceImport,
379                    ImportedName::SideEffect => ReferenceKind::SideEffectImport,
380                };
381
382                // Match to specific export
383                if let Some(export) = target_module
384                    .exports
385                    .iter_mut()
386                    .find(|e| export_matches(&e.name, &sym.imported_name))
387                {
388                    export.references.push(SymbolReference {
389                        from_file: source_id,
390                        kind: ref_kind,
391                    });
392                }
393
394                // Namespace imports: check if we can narrow to specific member accesses.
395                // `import * as ns from './x'; ns.foo; ns.bar` → only mark foo, bar as used.
396                // If the namespace variable is re-exported (`export { ns }`) or no member
397                // accesses are found, conservatively mark ALL exports as used.
398                if matches!(sym.imported_name, ImportedName::Namespace)
399                    && !sym.local_name.is_empty()
400                {
401                    let local_name = &sym.local_name;
402                    let source_mod = module_by_id.get(&source_id);
403                    let accessed_members: Vec<String> = source_mod
404                        .map(|m| {
405                            m.member_accesses
406                                .iter()
407                                .filter(|ma| ma.object == *local_name)
408                                .map(|ma| ma.member.clone())
409                                .collect()
410                        })
411                        .unwrap_or_default();
412
413                    // Check if the namespace variable is re-exported (export { ns } or export default ns)
414                    // from a NON-entry-point file. If the importing file IS an entry point,
415                    // the re-export is for external consumption and doesn't prove internal usage.
416                    let is_re_exported_from_non_entry = source_mod
417                        .map(|m| {
418                            m.exports
419                                .iter()
420                                .any(|e| e.local_name.as_deref() == Some(local_name.as_str()))
421                        })
422                        .unwrap_or(false)
423                        && !entry_point_ids.contains(&source_id);
424
425                    // For entry point files with no member accesses, the namespace
426                    // is purely re-exported for external use — don't mark all exports
427                    // as used internally. The `export *` path handles individual tracking.
428                    let is_entry_with_no_access =
429                        accessed_members.is_empty() && entry_point_ids.contains(&source_id);
430
431                    if !is_entry_with_no_access
432                        && (accessed_members.is_empty() || is_re_exported_from_non_entry)
433                    {
434                        // Can't narrow — mark all exports as referenced (conservative)
435                        for export in &mut target_module.exports {
436                            if export.references.iter().all(|r| r.from_file != source_id) {
437                                export.references.push(SymbolReference {
438                                    from_file: source_id,
439                                    kind: ReferenceKind::NamespaceImport,
440                                });
441                            }
442                        }
443                    } else {
444                        // Narrow: only mark accessed members as referenced
445                        for export in &mut target_module.exports {
446                            let name_str = export.name.to_string();
447                            if accessed_members.contains(&name_str)
448                                && export.references.iter().all(|r| r.from_file != source_id)
449                            {
450                                export.references.push(SymbolReference {
451                                    from_file: source_id,
452                                    kind: ReferenceKind::NamespaceImport,
453                                });
454                            }
455                        }
456                    }
457                } else if matches!(sym.imported_name, ImportedName::Namespace) {
458                    // No local name available — mark all (conservative)
459                    for export in &mut target_module.exports {
460                        if export.references.iter().all(|r| r.from_file != source_id) {
461                            export.references.push(SymbolReference {
462                                from_file: source_id,
463                                kind: ReferenceKind::NamespaceImport,
464                            });
465                        }
466                    }
467                }
468            }
469        }
470
471        // Mark reachable modules via BFS from entry points
472        let mut visited = FixedBitSet::with_capacity(total_capacity);
473        let mut queue = VecDeque::new();
474
475        for &ep_id in &entry_point_ids {
476            if (ep_id.0 as usize) < total_capacity {
477                visited.insert(ep_id.0 as usize);
478                queue.push_back(ep_id);
479            }
480        }
481
482        while let Some(file_id) = queue.pop_front() {
483            if (file_id.0 as usize) >= modules.len() {
484                continue;
485            }
486            let module = &modules[file_id.0 as usize];
487            for edge in &all_edges[module.edge_range.clone()] {
488                let target_idx = edge.target.0 as usize;
489                if target_idx < total_capacity && !visited.contains(target_idx) {
490                    visited.insert(target_idx);
491                    queue.push_back(edge.target);
492                }
493            }
494        }
495
496        for (idx, module) in modules.iter_mut().enumerate() {
497            module.is_reachable = visited.contains(idx);
498        }
499
500        let mut graph = Self {
501            modules,
502            edges: all_edges,
503            package_usage,
504            entry_points: entry_point_ids,
505            reverse_deps,
506            namespace_imported,
507        };
508
509        // Propagate references through re-export chains
510        graph.resolve_re_export_chains();
511
512        graph
513    }
514
515    /// Resolve re-export chains: when module A re-exports from B,
516    /// any reference to A's re-exported symbol should also count as a reference
517    /// to B's original export (and transitively through the chain).
518    fn resolve_re_export_chains(&mut self) {
519        // Collect re-export info: (barrel_file_id, source_file_id, imported_name, exported_name)
520        let re_export_info: Vec<(FileId, FileId, String, String)> = self
521            .modules
522            .iter()
523            .flat_map(|m| {
524                m.re_exports.iter().map(move |re| {
525                    (
526                        m.file_id,
527                        re.source_file,
528                        re.imported_name.clone(),
529                        re.exported_name.clone(),
530                    )
531                })
532            })
533            .collect();
534
535        if re_export_info.is_empty() {
536            return;
537        }
538
539        // For each re-export, if the barrel's exported symbol has references,
540        // propagate those references to the source module's original export.
541        // We iterate until no new references are added (handles chains).
542        let mut changed = true;
543        let max_iterations = 20; // prevent infinite loops on cycles
544        let mut iteration = 0;
545        // Reuse a single HashSet across iterations to avoid repeated allocations.
546        // In barrel-heavy monorepos, this loop can run up to max_iterations × re_export_info.len()
547        // × target_exports.len() times — reusing with .clear() avoids O(n) allocations.
548        let mut existing_refs: HashSet<FileId> = HashSet::new();
549
550        while changed && iteration < max_iterations {
551            changed = false;
552            iteration += 1;
553
554            for &(barrel_id, source_id, ref imported_name, ref exported_name) in &re_export_info {
555                let barrel_idx = barrel_id.0 as usize;
556                let source_idx = source_id.0 as usize;
557
558                if barrel_idx >= self.modules.len() || source_idx >= self.modules.len() {
559                    continue;
560                }
561
562                // Find references to the re-exported name on the barrel module
563                let refs_on_barrel: Vec<SymbolReference> = {
564                    let barrel = &self.modules[barrel_idx];
565                    barrel
566                        .exports
567                        .iter()
568                        .filter(|e| e.name.to_string() == *exported_name)
569                        .flat_map(|e| e.references.clone())
570                        .collect()
571                };
572
573                if refs_on_barrel.is_empty() {
574                    continue;
575                }
576
577                // Propagate to source module's export
578                let source = &mut self.modules[source_idx];
579                let target_exports: Vec<usize> = if imported_name == "*" {
580                    // Star re-export: all exports in source are candidates
581                    (0..source.exports.len()).collect()
582                } else {
583                    source
584                        .exports
585                        .iter()
586                        .enumerate()
587                        .filter(|(_, e)| e.name.to_string() == *imported_name)
588                        .map(|(i, _)| i)
589                        .collect()
590                };
591
592                for export_idx in target_exports {
593                    existing_refs.clear();
594                    existing_refs.extend(
595                        source.exports[export_idx]
596                            .references
597                            .iter()
598                            .map(|r| r.from_file),
599                    );
600                    for ref_item in &refs_on_barrel {
601                        if !existing_refs.contains(&ref_item.from_file) {
602                            source.exports[export_idx].references.push(ref_item.clone());
603                            changed = true;
604                        }
605                    }
606                }
607            }
608        }
609
610        if iteration >= max_iterations {
611            tracing::warn!(
612                iterations = max_iterations,
613                "Re-export chain resolution hit iteration limit, some chains may be incomplete"
614            );
615        }
616    }
617
618    /// Total number of modules.
619    pub fn module_count(&self) -> usize {
620        self.modules.len()
621    }
622
623    /// Total number of edges.
624    pub fn edge_count(&self) -> usize {
625        self.edges.len()
626    }
627
628    /// Check if any importer uses `import * as ns` for this module.
629    /// Uses precomputed bitset — O(1) lookup.
630    pub fn has_namespace_import(&self, file_id: FileId) -> bool {
631        let idx = file_id.0 as usize;
632        if idx >= self.namespace_imported.len() {
633            return false;
634        }
635        self.namespace_imported.contains(idx)
636    }
637}
638
639/// Check if an export name matches an imported name.
640fn export_matches(export: &ExportName, import: &ImportedName) -> bool {
641    match (export, import) {
642        (ExportName::Named(e), ImportedName::Named(i)) => e == i,
643        (ExportName::Default, ImportedName::Default) => true,
644        _ => false,
645    }
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651    use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
652    use crate::extract::{ExportName, ImportInfo, ImportedName};
653    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule, ResolvedReExport};
654    use std::path::PathBuf;
655
656    #[test]
657    fn export_matches_named_same() {
658        assert!(export_matches(
659            &ExportName::Named("foo".to_string()),
660            &ImportedName::Named("foo".to_string())
661        ));
662    }
663
664    #[test]
665    fn export_matches_named_different() {
666        assert!(!export_matches(
667            &ExportName::Named("foo".to_string()),
668            &ImportedName::Named("bar".to_string())
669        ));
670    }
671
672    #[test]
673    fn export_matches_default() {
674        assert!(export_matches(&ExportName::Default, &ImportedName::Default));
675    }
676
677    #[test]
678    fn export_matches_named_vs_default() {
679        assert!(!export_matches(
680            &ExportName::Named("foo".to_string()),
681            &ImportedName::Default
682        ));
683    }
684
685    #[test]
686    fn export_matches_default_vs_named() {
687        assert!(!export_matches(
688            &ExportName::Default,
689            &ImportedName::Named("foo".to_string())
690        ));
691    }
692
693    #[test]
694    fn export_matches_namespace_no_match() {
695        assert!(!export_matches(
696            &ExportName::Named("foo".to_string()),
697            &ImportedName::Namespace
698        ));
699        assert!(!export_matches(
700            &ExportName::Default,
701            &ImportedName::Namespace
702        ));
703    }
704
705    #[test]
706    fn export_matches_side_effect_no_match() {
707        assert!(!export_matches(
708            &ExportName::Named("foo".to_string()),
709            &ImportedName::SideEffect
710        ));
711    }
712
713    // Helper to build a simple module graph
714    fn build_simple_graph() -> ModuleGraph {
715        // Two files: entry.ts imports foo from utils.ts
716        let files = vec![
717            DiscoveredFile {
718                id: FileId(0),
719                path: PathBuf::from("/project/src/entry.ts"),
720                size_bytes: 100,
721            },
722            DiscoveredFile {
723                id: FileId(1),
724                path: PathBuf::from("/project/src/utils.ts"),
725                size_bytes: 50,
726            },
727        ];
728
729        let entry_points = vec![EntryPoint {
730            path: PathBuf::from("/project/src/entry.ts"),
731            source: EntryPointSource::PackageJsonMain,
732        }];
733
734        let resolved_modules = vec![
735            ResolvedModule {
736                file_id: FileId(0),
737                path: PathBuf::from("/project/src/entry.ts"),
738                exports: vec![],
739                re_exports: vec![],
740                resolved_imports: vec![ResolvedImport {
741                    info: ImportInfo {
742                        source: "./utils".to_string(),
743                        imported_name: ImportedName::Named("foo".to_string()),
744                        local_name: "foo".to_string(),
745                        is_type_only: false,
746                        span: oxc_span::Span::new(0, 10),
747                    },
748                    target: ResolveResult::InternalModule(FileId(1)),
749                }],
750                resolved_dynamic_imports: vec![],
751                resolved_dynamic_patterns: vec![],
752                member_accesses: vec![],
753                whole_object_uses: vec![],
754                has_cjs_exports: false,
755            },
756            ResolvedModule {
757                file_id: FileId(1),
758                path: PathBuf::from("/project/src/utils.ts"),
759                exports: vec![
760                    crate::extract::ExportInfo {
761                        name: ExportName::Named("foo".to_string()),
762                        local_name: Some("foo".to_string()),
763                        is_type_only: false,
764                        span: oxc_span::Span::new(0, 20),
765                        members: vec![],
766                    },
767                    crate::extract::ExportInfo {
768                        name: ExportName::Named("bar".to_string()),
769                        local_name: Some("bar".to_string()),
770                        is_type_only: false,
771                        span: oxc_span::Span::new(25, 45),
772                        members: vec![],
773                    },
774                ],
775                re_exports: vec![],
776                resolved_imports: vec![],
777                resolved_dynamic_imports: vec![],
778                resolved_dynamic_patterns: vec![],
779                member_accesses: vec![],
780                whole_object_uses: vec![],
781                has_cjs_exports: false,
782            },
783        ];
784
785        ModuleGraph::build(&resolved_modules, &entry_points, &files)
786    }
787
788    #[test]
789    fn graph_module_count() {
790        let graph = build_simple_graph();
791        assert_eq!(graph.module_count(), 2);
792    }
793
794    #[test]
795    fn graph_edge_count() {
796        let graph = build_simple_graph();
797        assert_eq!(graph.edge_count(), 1);
798    }
799
800    #[test]
801    fn graph_entry_point_is_reachable() {
802        let graph = build_simple_graph();
803        assert!(graph.modules[0].is_entry_point);
804        assert!(graph.modules[0].is_reachable);
805    }
806
807    #[test]
808    fn graph_imported_module_is_reachable() {
809        let graph = build_simple_graph();
810        assert!(!graph.modules[1].is_entry_point);
811        assert!(graph.modules[1].is_reachable);
812    }
813
814    #[test]
815    fn graph_export_has_reference() {
816        let graph = build_simple_graph();
817        let utils = &graph.modules[1];
818        let foo_export = utils
819            .exports
820            .iter()
821            .find(|e| e.name.to_string() == "foo")
822            .unwrap();
823        assert!(
824            !foo_export.references.is_empty(),
825            "foo should have references"
826        );
827    }
828
829    #[test]
830    fn graph_unused_export_no_reference() {
831        let graph = build_simple_graph();
832        let utils = &graph.modules[1];
833        let bar_export = utils
834            .exports
835            .iter()
836            .find(|e| e.name.to_string() == "bar")
837            .unwrap();
838        assert!(
839            bar_export.references.is_empty(),
840            "bar should have no references"
841        );
842    }
843
844    #[test]
845    fn graph_no_namespace_import() {
846        let graph = build_simple_graph();
847        assert!(!graph.has_namespace_import(FileId(0)));
848        assert!(!graph.has_namespace_import(FileId(1)));
849    }
850
851    #[test]
852    fn graph_has_namespace_import() {
853        let files = vec![
854            DiscoveredFile {
855                id: FileId(0),
856                path: PathBuf::from("/project/entry.ts"),
857                size_bytes: 100,
858            },
859            DiscoveredFile {
860                id: FileId(1),
861                path: PathBuf::from("/project/utils.ts"),
862                size_bytes: 50,
863            },
864        ];
865
866        let entry_points = vec![EntryPoint {
867            path: PathBuf::from("/project/entry.ts"),
868            source: EntryPointSource::PackageJsonMain,
869        }];
870
871        let resolved_modules = vec![
872            ResolvedModule {
873                file_id: FileId(0),
874                path: PathBuf::from("/project/entry.ts"),
875                exports: vec![],
876                re_exports: vec![],
877                resolved_imports: vec![ResolvedImport {
878                    info: ImportInfo {
879                        source: "./utils".to_string(),
880                        imported_name: ImportedName::Namespace,
881                        local_name: "utils".to_string(),
882                        is_type_only: false,
883                        span: oxc_span::Span::new(0, 10),
884                    },
885                    target: ResolveResult::InternalModule(FileId(1)),
886                }],
887                resolved_dynamic_imports: vec![],
888                resolved_dynamic_patterns: vec![],
889                member_accesses: vec![],
890                whole_object_uses: vec![],
891                has_cjs_exports: false,
892            },
893            ResolvedModule {
894                file_id: FileId(1),
895                path: PathBuf::from("/project/utils.ts"),
896                exports: vec![crate::extract::ExportInfo {
897                    name: ExportName::Named("foo".to_string()),
898                    local_name: Some("foo".to_string()),
899                    is_type_only: false,
900                    span: oxc_span::Span::new(0, 20),
901                    members: vec![],
902                }],
903                re_exports: vec![],
904                resolved_imports: vec![],
905                resolved_dynamic_imports: vec![],
906                resolved_dynamic_patterns: vec![],
907                member_accesses: vec![],
908                whole_object_uses: vec![],
909                has_cjs_exports: false,
910            },
911        ];
912
913        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
914        assert!(
915            graph.has_namespace_import(FileId(1)),
916            "utils should have namespace import"
917        );
918    }
919
920    #[test]
921    fn graph_has_namespace_import_out_of_bounds() {
922        let graph = build_simple_graph();
923        assert!(!graph.has_namespace_import(FileId(999)));
924    }
925
926    #[test]
927    fn graph_unreachable_module() {
928        // Three files: entry imports utils, orphan is not imported
929        let files = vec![
930            DiscoveredFile {
931                id: FileId(0),
932                path: PathBuf::from("/project/entry.ts"),
933                size_bytes: 100,
934            },
935            DiscoveredFile {
936                id: FileId(1),
937                path: PathBuf::from("/project/utils.ts"),
938                size_bytes: 50,
939            },
940            DiscoveredFile {
941                id: FileId(2),
942                path: PathBuf::from("/project/orphan.ts"),
943                size_bytes: 30,
944            },
945        ];
946
947        let entry_points = vec![EntryPoint {
948            path: PathBuf::from("/project/entry.ts"),
949            source: EntryPointSource::PackageJsonMain,
950        }];
951
952        let resolved_modules = vec![
953            ResolvedModule {
954                file_id: FileId(0),
955                path: PathBuf::from("/project/entry.ts"),
956                exports: vec![],
957                re_exports: vec![],
958                resolved_imports: vec![ResolvedImport {
959                    info: ImportInfo {
960                        source: "./utils".to_string(),
961                        imported_name: ImportedName::Named("foo".to_string()),
962                        local_name: "foo".to_string(),
963                        is_type_only: false,
964                        span: oxc_span::Span::new(0, 10),
965                    },
966                    target: ResolveResult::InternalModule(FileId(1)),
967                }],
968                resolved_dynamic_imports: vec![],
969                resolved_dynamic_patterns: vec![],
970                member_accesses: vec![],
971                whole_object_uses: vec![],
972                has_cjs_exports: false,
973            },
974            ResolvedModule {
975                file_id: FileId(1),
976                path: PathBuf::from("/project/utils.ts"),
977                exports: vec![crate::extract::ExportInfo {
978                    name: ExportName::Named("foo".to_string()),
979                    local_name: Some("foo".to_string()),
980                    is_type_only: false,
981                    span: oxc_span::Span::new(0, 20),
982                    members: vec![],
983                }],
984                re_exports: vec![],
985                resolved_imports: vec![],
986                resolved_dynamic_imports: vec![],
987                resolved_dynamic_patterns: vec![],
988                member_accesses: vec![],
989                whole_object_uses: vec![],
990                has_cjs_exports: false,
991            },
992            ResolvedModule {
993                file_id: FileId(2),
994                path: PathBuf::from("/project/orphan.ts"),
995                exports: vec![crate::extract::ExportInfo {
996                    name: ExportName::Named("orphan".to_string()),
997                    local_name: Some("orphan".to_string()),
998                    is_type_only: false,
999                    span: oxc_span::Span::new(0, 20),
1000                    members: vec![],
1001                }],
1002                re_exports: vec![],
1003                resolved_imports: vec![],
1004                resolved_dynamic_imports: vec![],
1005                resolved_dynamic_patterns: vec![],
1006                member_accesses: vec![],
1007                whole_object_uses: vec![],
1008                has_cjs_exports: false,
1009            },
1010        ];
1011
1012        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1013
1014        assert!(graph.modules[0].is_reachable, "entry should be reachable");
1015        assert!(graph.modules[1].is_reachable, "utils should be reachable");
1016        assert!(
1017            !graph.modules[2].is_reachable,
1018            "orphan should NOT be reachable"
1019        );
1020    }
1021
1022    #[test]
1023    fn graph_package_usage_tracked() {
1024        let files = vec![DiscoveredFile {
1025            id: FileId(0),
1026            path: PathBuf::from("/project/entry.ts"),
1027            size_bytes: 100,
1028        }];
1029
1030        let entry_points = vec![EntryPoint {
1031            path: PathBuf::from("/project/entry.ts"),
1032            source: EntryPointSource::PackageJsonMain,
1033        }];
1034
1035        let resolved_modules = vec![ResolvedModule {
1036            file_id: FileId(0),
1037            path: PathBuf::from("/project/entry.ts"),
1038            exports: vec![],
1039            re_exports: vec![],
1040            resolved_imports: vec![
1041                ResolvedImport {
1042                    info: ImportInfo {
1043                        source: "react".to_string(),
1044                        imported_name: ImportedName::Default,
1045                        local_name: "React".to_string(),
1046                        is_type_only: false,
1047                        span: oxc_span::Span::new(0, 10),
1048                    },
1049                    target: ResolveResult::NpmPackage("react".to_string()),
1050                },
1051                ResolvedImport {
1052                    info: ImportInfo {
1053                        source: "lodash".to_string(),
1054                        imported_name: ImportedName::Named("merge".to_string()),
1055                        local_name: "merge".to_string(),
1056                        is_type_only: false,
1057                        span: oxc_span::Span::new(15, 30),
1058                    },
1059                    target: ResolveResult::NpmPackage("lodash".to_string()),
1060                },
1061            ],
1062            resolved_dynamic_imports: vec![],
1063            resolved_dynamic_patterns: vec![],
1064            member_accesses: vec![],
1065            whole_object_uses: vec![],
1066            has_cjs_exports: false,
1067        }];
1068
1069        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1070        assert!(graph.package_usage.contains_key("react"));
1071        assert!(graph.package_usage.contains_key("lodash"));
1072        assert!(!graph.package_usage.contains_key("express"));
1073    }
1074
1075    #[test]
1076    fn graph_re_export_chain_propagates_references() {
1077        // entry.ts -> barrel.ts -re-exports-> source.ts
1078        let files = vec![
1079            DiscoveredFile {
1080                id: FileId(0),
1081                path: PathBuf::from("/project/entry.ts"),
1082                size_bytes: 100,
1083            },
1084            DiscoveredFile {
1085                id: FileId(1),
1086                path: PathBuf::from("/project/barrel.ts"),
1087                size_bytes: 50,
1088            },
1089            DiscoveredFile {
1090                id: FileId(2),
1091                path: PathBuf::from("/project/source.ts"),
1092                size_bytes: 50,
1093            },
1094        ];
1095
1096        let entry_points = vec![EntryPoint {
1097            path: PathBuf::from("/project/entry.ts"),
1098            source: EntryPointSource::PackageJsonMain,
1099        }];
1100
1101        let resolved_modules = vec![
1102            // entry imports "foo" from barrel
1103            ResolvedModule {
1104                file_id: FileId(0),
1105                path: PathBuf::from("/project/entry.ts"),
1106                exports: vec![],
1107                re_exports: vec![],
1108                resolved_imports: vec![ResolvedImport {
1109                    info: ImportInfo {
1110                        source: "./barrel".to_string(),
1111                        imported_name: ImportedName::Named("foo".to_string()),
1112                        local_name: "foo".to_string(),
1113                        is_type_only: false,
1114                        span: oxc_span::Span::new(0, 10),
1115                    },
1116                    target: ResolveResult::InternalModule(FileId(1)),
1117                }],
1118                resolved_dynamic_imports: vec![],
1119                resolved_dynamic_patterns: vec![],
1120                member_accesses: vec![],
1121                whole_object_uses: vec![],
1122                has_cjs_exports: false,
1123            },
1124            // barrel re-exports "foo" from source
1125            ResolvedModule {
1126                file_id: FileId(1),
1127                path: PathBuf::from("/project/barrel.ts"),
1128                exports: vec![crate::extract::ExportInfo {
1129                    name: ExportName::Named("foo".to_string()),
1130                    local_name: Some("foo".to_string()),
1131                    is_type_only: false,
1132                    span: oxc_span::Span::new(0, 20),
1133                    members: vec![],
1134                }],
1135                re_exports: vec![ResolvedReExport {
1136                    info: crate::extract::ReExportInfo {
1137                        source: "./source".to_string(),
1138                        imported_name: "foo".to_string(),
1139                        exported_name: "foo".to_string(),
1140                        is_type_only: false,
1141                    },
1142                    target: ResolveResult::InternalModule(FileId(2)),
1143                }],
1144                resolved_imports: vec![],
1145                resolved_dynamic_imports: vec![],
1146                resolved_dynamic_patterns: vec![],
1147                member_accesses: vec![],
1148                whole_object_uses: vec![],
1149                has_cjs_exports: false,
1150            },
1151            // source has the actual export
1152            ResolvedModule {
1153                file_id: FileId(2),
1154                path: PathBuf::from("/project/source.ts"),
1155                exports: vec![crate::extract::ExportInfo {
1156                    name: ExportName::Named("foo".to_string()),
1157                    local_name: Some("foo".to_string()),
1158                    is_type_only: false,
1159                    span: oxc_span::Span::new(0, 20),
1160                    members: vec![],
1161                }],
1162                re_exports: vec![],
1163                resolved_imports: vec![],
1164                resolved_dynamic_imports: vec![],
1165                resolved_dynamic_patterns: vec![],
1166                member_accesses: vec![],
1167                whole_object_uses: vec![],
1168                has_cjs_exports: false,
1169            },
1170        ];
1171
1172        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1173
1174        // The source module's "foo" export should have references propagated through the barrel
1175        let source_module = &graph.modules[2];
1176        let foo_export = source_module
1177            .exports
1178            .iter()
1179            .find(|e| e.name.to_string() == "foo")
1180            .unwrap();
1181        assert!(
1182            !foo_export.references.is_empty(),
1183            "source foo should have propagated references through barrel re-export chain"
1184        );
1185    }
1186
1187    #[test]
1188    fn graph_empty() {
1189        let graph = ModuleGraph::build(&[], &[], &[]);
1190        assert_eq!(graph.module_count(), 0);
1191        assert_eq!(graph.edge_count(), 0);
1192    }
1193
1194    #[test]
1195    fn graph_cjs_exports_tracked() {
1196        let files = vec![DiscoveredFile {
1197            id: FileId(0),
1198            path: PathBuf::from("/project/entry.ts"),
1199            size_bytes: 100,
1200        }];
1201
1202        let entry_points = vec![EntryPoint {
1203            path: PathBuf::from("/project/entry.ts"),
1204            source: EntryPointSource::PackageJsonMain,
1205        }];
1206
1207        let resolved_modules = vec![ResolvedModule {
1208            file_id: FileId(0),
1209            path: PathBuf::from("/project/entry.ts"),
1210            exports: vec![],
1211            re_exports: vec![],
1212            resolved_imports: vec![],
1213            resolved_dynamic_imports: vec![],
1214            resolved_dynamic_patterns: vec![],
1215            member_accesses: vec![],
1216            whole_object_uses: vec![],
1217            has_cjs_exports: true,
1218        }];
1219
1220        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1221        assert!(graph.modules[0].has_cjs_exports);
1222    }
1223
1224    #[test]
1225    fn barrel_re_export_creates_export_symbol() {
1226        // barrel.ts has `export { foo } from './source'`
1227        // The barrel should have an ExportSymbol for "foo" so references can attach.
1228        let files = vec![
1229            DiscoveredFile {
1230                id: FileId(0),
1231                path: PathBuf::from("/project/entry.ts"),
1232                size_bytes: 100,
1233            },
1234            DiscoveredFile {
1235                id: FileId(1),
1236                path: PathBuf::from("/project/barrel.ts"),
1237                size_bytes: 50,
1238            },
1239            DiscoveredFile {
1240                id: FileId(2),
1241                path: PathBuf::from("/project/source.ts"),
1242                size_bytes: 50,
1243            },
1244        ];
1245
1246        let entry_points = vec![EntryPoint {
1247            path: PathBuf::from("/project/entry.ts"),
1248            source: EntryPointSource::PackageJsonMain,
1249        }];
1250
1251        let resolved_modules = vec![
1252            ResolvedModule {
1253                file_id: FileId(0),
1254                path: PathBuf::from("/project/entry.ts"),
1255                exports: vec![],
1256                re_exports: vec![],
1257                resolved_imports: vec![ResolvedImport {
1258                    info: ImportInfo {
1259                        source: "./barrel".to_string(),
1260                        imported_name: ImportedName::Named("foo".to_string()),
1261                        local_name: "foo".to_string(),
1262                        is_type_only: false,
1263                        span: oxc_span::Span::new(0, 10),
1264                    },
1265                    target: ResolveResult::InternalModule(FileId(1)),
1266                }],
1267                resolved_dynamic_imports: vec![],
1268                resolved_dynamic_patterns: vec![],
1269                member_accesses: vec![],
1270                whole_object_uses: vec![],
1271                has_cjs_exports: false,
1272            },
1273            // barrel re-exports "foo" from source (no local exports)
1274            ResolvedModule {
1275                file_id: FileId(1),
1276                path: PathBuf::from("/project/barrel.ts"),
1277                exports: vec![], // barrel has NO local exports
1278                re_exports: vec![ResolvedReExport {
1279                    info: crate::extract::ReExportInfo {
1280                        source: "./source".to_string(),
1281                        imported_name: "foo".to_string(),
1282                        exported_name: "foo".to_string(),
1283                        is_type_only: false,
1284                    },
1285                    target: ResolveResult::InternalModule(FileId(2)),
1286                }],
1287                resolved_imports: vec![],
1288                resolved_dynamic_imports: vec![],
1289                resolved_dynamic_patterns: vec![],
1290                member_accesses: vec![],
1291                whole_object_uses: vec![],
1292                has_cjs_exports: false,
1293            },
1294            ResolvedModule {
1295                file_id: FileId(2),
1296                path: PathBuf::from("/project/source.ts"),
1297                exports: vec![crate::extract::ExportInfo {
1298                    name: ExportName::Named("foo".to_string()),
1299                    local_name: Some("foo".to_string()),
1300                    is_type_only: false,
1301                    span: oxc_span::Span::new(0, 20),
1302                    members: vec![],
1303                }],
1304                re_exports: vec![],
1305                resolved_imports: vec![],
1306                resolved_dynamic_imports: vec![],
1307                resolved_dynamic_patterns: vec![],
1308                member_accesses: vec![],
1309                whole_object_uses: vec![],
1310                has_cjs_exports: false,
1311            },
1312        ];
1313
1314        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1315
1316        // The barrel should have an ExportSymbol for "foo" created from its re-export
1317        let barrel = &graph.modules[1];
1318        let foo_export = barrel.exports.iter().find(|e| e.name.to_string() == "foo");
1319        assert!(
1320            foo_export.is_some(),
1321            "barrel should have ExportSymbol for re-exported 'foo'"
1322        );
1323
1324        // The barrel's foo export should have a reference from entry.ts
1325        let foo = foo_export.unwrap();
1326        assert!(
1327            !foo.references.is_empty(),
1328            "barrel's foo should have a reference from entry.ts"
1329        );
1330
1331        // The source module's foo should also have propagated references
1332        let source = &graph.modules[2];
1333        let source_foo = source
1334            .exports
1335            .iter()
1336            .find(|e| e.name.to_string() == "foo")
1337            .unwrap();
1338        assert!(
1339            !source_foo.references.is_empty(),
1340            "source foo should have propagated references through barrel"
1341        );
1342    }
1343
1344    #[test]
1345    fn barrel_unused_re_export_has_no_references() {
1346        // barrel.ts re-exports both foo and bar from source
1347        // entry.ts only imports foo — bar should have no references on barrel
1348        let files = vec![
1349            DiscoveredFile {
1350                id: FileId(0),
1351                path: PathBuf::from("/project/entry.ts"),
1352                size_bytes: 100,
1353            },
1354            DiscoveredFile {
1355                id: FileId(1),
1356                path: PathBuf::from("/project/barrel.ts"),
1357                size_bytes: 50,
1358            },
1359            DiscoveredFile {
1360                id: FileId(2),
1361                path: PathBuf::from("/project/source.ts"),
1362                size_bytes: 50,
1363            },
1364        ];
1365
1366        let entry_points = vec![EntryPoint {
1367            path: PathBuf::from("/project/entry.ts"),
1368            source: EntryPointSource::PackageJsonMain,
1369        }];
1370
1371        let resolved_modules = vec![
1372            ResolvedModule {
1373                file_id: FileId(0),
1374                path: PathBuf::from("/project/entry.ts"),
1375                exports: vec![],
1376                re_exports: vec![],
1377                resolved_imports: vec![ResolvedImport {
1378                    info: ImportInfo {
1379                        source: "./barrel".to_string(),
1380                        imported_name: ImportedName::Named("foo".to_string()),
1381                        local_name: "foo".to_string(),
1382                        is_type_only: false,
1383                        span: oxc_span::Span::new(0, 10),
1384                    },
1385                    target: ResolveResult::InternalModule(FileId(1)),
1386                }],
1387                resolved_dynamic_imports: vec![],
1388                resolved_dynamic_patterns: vec![],
1389                member_accesses: vec![],
1390                whole_object_uses: vec![],
1391                has_cjs_exports: false,
1392            },
1393            ResolvedModule {
1394                file_id: FileId(1),
1395                path: PathBuf::from("/project/barrel.ts"),
1396                exports: vec![],
1397                re_exports: vec![
1398                    ResolvedReExport {
1399                        info: crate::extract::ReExportInfo {
1400                            source: "./source".to_string(),
1401                            imported_name: "foo".to_string(),
1402                            exported_name: "foo".to_string(),
1403                            is_type_only: false,
1404                        },
1405                        target: ResolveResult::InternalModule(FileId(2)),
1406                    },
1407                    ResolvedReExport {
1408                        info: crate::extract::ReExportInfo {
1409                            source: "./source".to_string(),
1410                            imported_name: "bar".to_string(),
1411                            exported_name: "bar".to_string(),
1412                            is_type_only: false,
1413                        },
1414                        target: ResolveResult::InternalModule(FileId(2)),
1415                    },
1416                ],
1417                resolved_imports: vec![],
1418                resolved_dynamic_imports: vec![],
1419                resolved_dynamic_patterns: vec![],
1420                member_accesses: vec![],
1421                whole_object_uses: vec![],
1422                has_cjs_exports: false,
1423            },
1424            ResolvedModule {
1425                file_id: FileId(2),
1426                path: PathBuf::from("/project/source.ts"),
1427                exports: vec![
1428                    crate::extract::ExportInfo {
1429                        name: ExportName::Named("foo".to_string()),
1430                        local_name: Some("foo".to_string()),
1431                        is_type_only: false,
1432                        span: oxc_span::Span::new(0, 20),
1433                        members: vec![],
1434                    },
1435                    crate::extract::ExportInfo {
1436                        name: ExportName::Named("bar".to_string()),
1437                        local_name: Some("bar".to_string()),
1438                        is_type_only: false,
1439                        span: oxc_span::Span::new(25, 45),
1440                        members: vec![],
1441                    },
1442                ],
1443                re_exports: vec![],
1444                resolved_imports: vec![],
1445                resolved_dynamic_imports: vec![],
1446                resolved_dynamic_patterns: vec![],
1447                member_accesses: vec![],
1448                whole_object_uses: vec![],
1449                has_cjs_exports: false,
1450            },
1451        ];
1452
1453        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1454
1455        let barrel = &graph.modules[1];
1456        // foo should be referenced, bar should NOT
1457        let foo = barrel
1458            .exports
1459            .iter()
1460            .find(|e| e.name.to_string() == "foo")
1461            .unwrap();
1462        assert!(!foo.references.is_empty(), "barrel's foo should be used");
1463
1464        let bar = barrel
1465            .exports
1466            .iter()
1467            .find(|e| e.name.to_string() == "bar")
1468            .unwrap();
1469        assert!(
1470            bar.references.is_empty(),
1471            "barrel's bar should be unused (no consumer imports it)"
1472        );
1473    }
1474
1475    #[test]
1476    fn type_only_re_export_creates_type_only_export_symbol() {
1477        // barrel has: export type { FooType } from './source'
1478        let files = vec![
1479            DiscoveredFile {
1480                id: FileId(0),
1481                path: PathBuf::from("/project/entry.ts"),
1482                size_bytes: 100,
1483            },
1484            DiscoveredFile {
1485                id: FileId(1),
1486                path: PathBuf::from("/project/barrel.ts"),
1487                size_bytes: 50,
1488            },
1489            DiscoveredFile {
1490                id: FileId(2),
1491                path: PathBuf::from("/project/source.ts"),
1492                size_bytes: 50,
1493            },
1494        ];
1495
1496        let entry_points = vec![EntryPoint {
1497            path: PathBuf::from("/project/entry.ts"),
1498            source: EntryPointSource::PackageJsonMain,
1499        }];
1500
1501        let resolved_modules = vec![
1502            ResolvedModule {
1503                file_id: FileId(0),
1504                path: PathBuf::from("/project/entry.ts"),
1505                exports: vec![],
1506                re_exports: vec![],
1507                resolved_imports: vec![ResolvedImport {
1508                    info: ImportInfo {
1509                        source: "./barrel".to_string(),
1510                        imported_name: ImportedName::Named("UsedType".to_string()),
1511                        local_name: "UsedType".to_string(),
1512                        is_type_only: true,
1513                        span: oxc_span::Span::new(0, 10),
1514                    },
1515                    target: ResolveResult::InternalModule(FileId(1)),
1516                }],
1517                resolved_dynamic_imports: vec![],
1518                resolved_dynamic_patterns: vec![],
1519                member_accesses: vec![],
1520                whole_object_uses: vec![],
1521                has_cjs_exports: false,
1522            },
1523            ResolvedModule {
1524                file_id: FileId(1),
1525                path: PathBuf::from("/project/barrel.ts"),
1526                exports: vec![],
1527                re_exports: vec![
1528                    ResolvedReExport {
1529                        info: crate::extract::ReExportInfo {
1530                            source: "./source".to_string(),
1531                            imported_name: "UsedType".to_string(),
1532                            exported_name: "UsedType".to_string(),
1533                            is_type_only: true,
1534                        },
1535                        target: ResolveResult::InternalModule(FileId(2)),
1536                    },
1537                    ResolvedReExport {
1538                        info: crate::extract::ReExportInfo {
1539                            source: "./source".to_string(),
1540                            imported_name: "UnusedType".to_string(),
1541                            exported_name: "UnusedType".to_string(),
1542                            is_type_only: true,
1543                        },
1544                        target: ResolveResult::InternalModule(FileId(2)),
1545                    },
1546                ],
1547                resolved_imports: vec![],
1548                resolved_dynamic_imports: vec![],
1549                resolved_dynamic_patterns: vec![],
1550                member_accesses: vec![],
1551                whole_object_uses: vec![],
1552                has_cjs_exports: false,
1553            },
1554            ResolvedModule {
1555                file_id: FileId(2),
1556                path: PathBuf::from("/project/source.ts"),
1557                exports: vec![
1558                    crate::extract::ExportInfo {
1559                        name: ExportName::Named("UsedType".to_string()),
1560                        local_name: Some("UsedType".to_string()),
1561                        is_type_only: true,
1562                        span: oxc_span::Span::new(0, 20),
1563                        members: vec![],
1564                    },
1565                    crate::extract::ExportInfo {
1566                        name: ExportName::Named("UnusedType".to_string()),
1567                        local_name: Some("UnusedType".to_string()),
1568                        is_type_only: true,
1569                        span: oxc_span::Span::new(25, 45),
1570                        members: vec![],
1571                    },
1572                ],
1573                re_exports: vec![],
1574                resolved_imports: vec![],
1575                resolved_dynamic_imports: vec![],
1576                resolved_dynamic_patterns: vec![],
1577                member_accesses: vec![],
1578                whole_object_uses: vec![],
1579                has_cjs_exports: false,
1580            },
1581        ];
1582
1583        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1584
1585        let barrel = &graph.modules[1];
1586
1587        // Both type re-exports should create type-only ExportSymbols
1588        let used_type = barrel
1589            .exports
1590            .iter()
1591            .find(|e| e.name.to_string() == "UsedType")
1592            .expect("barrel should have ExportSymbol for UsedType");
1593        assert!(used_type.is_type_only, "UsedType should be type-only");
1594        assert!(
1595            !used_type.references.is_empty(),
1596            "UsedType should have references"
1597        );
1598
1599        let unused_type = barrel
1600            .exports
1601            .iter()
1602            .find(|e| e.name.to_string() == "UnusedType")
1603            .expect("barrel should have ExportSymbol for UnusedType");
1604        assert!(unused_type.is_type_only, "UnusedType should be type-only");
1605        assert!(
1606            unused_type.references.is_empty(),
1607            "UnusedType should have no references"
1608        );
1609    }
1610
1611    #[test]
1612    fn default_re_export_creates_default_export_symbol() {
1613        // barrel has: export { default as Accordion } from './Accordion'
1614        let files = vec![
1615            DiscoveredFile {
1616                id: FileId(0),
1617                path: PathBuf::from("/project/entry.ts"),
1618                size_bytes: 100,
1619            },
1620            DiscoveredFile {
1621                id: FileId(1),
1622                path: PathBuf::from("/project/barrel.ts"),
1623                size_bytes: 50,
1624            },
1625            DiscoveredFile {
1626                id: FileId(2),
1627                path: PathBuf::from("/project/source.ts"),
1628                size_bytes: 50,
1629            },
1630        ];
1631
1632        let entry_points = vec![EntryPoint {
1633            path: PathBuf::from("/project/entry.ts"),
1634            source: EntryPointSource::PackageJsonMain,
1635        }];
1636
1637        let resolved_modules = vec![
1638            ResolvedModule {
1639                file_id: FileId(0),
1640                path: PathBuf::from("/project/entry.ts"),
1641                exports: vec![],
1642                re_exports: vec![],
1643                resolved_imports: vec![ResolvedImport {
1644                    info: ImportInfo {
1645                        source: "./barrel".to_string(),
1646                        imported_name: ImportedName::Named("Accordion".to_string()),
1647                        local_name: "Accordion".to_string(),
1648                        is_type_only: false,
1649                        span: oxc_span::Span::new(0, 10),
1650                    },
1651                    target: ResolveResult::InternalModule(FileId(1)),
1652                }],
1653                resolved_dynamic_imports: vec![],
1654                resolved_dynamic_patterns: vec![],
1655                member_accesses: vec![],
1656                whole_object_uses: vec![],
1657                has_cjs_exports: false,
1658            },
1659            ResolvedModule {
1660                file_id: FileId(1),
1661                path: PathBuf::from("/project/barrel.ts"),
1662                exports: vec![],
1663                re_exports: vec![ResolvedReExport {
1664                    info: crate::extract::ReExportInfo {
1665                        source: "./source".to_string(),
1666                        imported_name: "default".to_string(),
1667                        exported_name: "Accordion".to_string(),
1668                        is_type_only: false,
1669                    },
1670                    target: ResolveResult::InternalModule(FileId(2)),
1671                }],
1672                resolved_imports: vec![],
1673                resolved_dynamic_imports: vec![],
1674                resolved_dynamic_patterns: vec![],
1675                member_accesses: vec![],
1676                whole_object_uses: vec![],
1677                has_cjs_exports: false,
1678            },
1679            ResolvedModule {
1680                file_id: FileId(2),
1681                path: PathBuf::from("/project/source.ts"),
1682                exports: vec![crate::extract::ExportInfo {
1683                    name: ExportName::Default,
1684                    local_name: None,
1685                    is_type_only: false,
1686                    span: oxc_span::Span::new(0, 20),
1687                    members: vec![],
1688                }],
1689                re_exports: vec![],
1690                resolved_imports: vec![],
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        ];
1698
1699        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1700
1701        // Barrel should have "Accordion" as an export
1702        let barrel = &graph.modules[1];
1703        let accordion = barrel
1704            .exports
1705            .iter()
1706            .find(|e| e.name.to_string() == "Accordion")
1707            .expect("barrel should have ExportSymbol for Accordion");
1708        assert!(
1709            !accordion.references.is_empty(),
1710            "Accordion should have reference from entry.ts"
1711        );
1712
1713        // Source's default export should have propagated references
1714        let source = &graph.modules[2];
1715        let default_export = source
1716            .exports
1717            .iter()
1718            .find(|e| matches!(e.name, ExportName::Default))
1719            .unwrap();
1720        assert!(
1721            !default_export.references.is_empty(),
1722            "source default export should have propagated references"
1723        );
1724    }
1725
1726    #[test]
1727    fn multi_level_re_export_chain_propagation() {
1728        // entry.ts -> barrel1.ts -re-exports-> barrel2.ts -re-exports-> source.ts
1729        let files = vec![
1730            DiscoveredFile {
1731                id: FileId(0),
1732                path: PathBuf::from("/project/entry.ts"),
1733                size_bytes: 100,
1734            },
1735            DiscoveredFile {
1736                id: FileId(1),
1737                path: PathBuf::from("/project/barrel1.ts"),
1738                size_bytes: 50,
1739            },
1740            DiscoveredFile {
1741                id: FileId(2),
1742                path: PathBuf::from("/project/barrel2.ts"),
1743                size_bytes: 50,
1744            },
1745            DiscoveredFile {
1746                id: FileId(3),
1747                path: PathBuf::from("/project/source.ts"),
1748                size_bytes: 50,
1749            },
1750        ];
1751
1752        let entry_points = vec![EntryPoint {
1753            path: PathBuf::from("/project/entry.ts"),
1754            source: EntryPointSource::PackageJsonMain,
1755        }];
1756
1757        let resolved_modules = vec![
1758            ResolvedModule {
1759                file_id: FileId(0),
1760                path: PathBuf::from("/project/entry.ts"),
1761                exports: vec![],
1762                re_exports: vec![],
1763                resolved_imports: vec![ResolvedImport {
1764                    info: ImportInfo {
1765                        source: "./barrel1".to_string(),
1766                        imported_name: ImportedName::Named("foo".to_string()),
1767                        local_name: "foo".to_string(),
1768                        is_type_only: false,
1769                        span: oxc_span::Span::new(0, 10),
1770                    },
1771                    target: ResolveResult::InternalModule(FileId(1)),
1772                }],
1773                resolved_dynamic_imports: vec![],
1774                resolved_dynamic_patterns: vec![],
1775                member_accesses: vec![],
1776                whole_object_uses: vec![],
1777                has_cjs_exports: false,
1778            },
1779            // barrel1 re-exports foo from barrel2
1780            ResolvedModule {
1781                file_id: FileId(1),
1782                path: PathBuf::from("/project/barrel1.ts"),
1783                exports: vec![],
1784                re_exports: vec![ResolvedReExport {
1785                    info: crate::extract::ReExportInfo {
1786                        source: "./barrel2".to_string(),
1787                        imported_name: "foo".to_string(),
1788                        exported_name: "foo".to_string(),
1789                        is_type_only: false,
1790                    },
1791                    target: ResolveResult::InternalModule(FileId(2)),
1792                }],
1793                resolved_imports: vec![],
1794                resolved_dynamic_imports: vec![],
1795                resolved_dynamic_patterns: vec![],
1796                member_accesses: vec![],
1797                whole_object_uses: vec![],
1798                has_cjs_exports: false,
1799            },
1800            // barrel2 re-exports foo from source
1801            ResolvedModule {
1802                file_id: FileId(2),
1803                path: PathBuf::from("/project/barrel2.ts"),
1804                exports: vec![],
1805                re_exports: vec![ResolvedReExport {
1806                    info: crate::extract::ReExportInfo {
1807                        source: "./source".to_string(),
1808                        imported_name: "foo".to_string(),
1809                        exported_name: "foo".to_string(),
1810                        is_type_only: false,
1811                    },
1812                    target: ResolveResult::InternalModule(FileId(3)),
1813                }],
1814                resolved_imports: vec![],
1815                resolved_dynamic_imports: vec![],
1816                resolved_dynamic_patterns: vec![],
1817                member_accesses: vec![],
1818                whole_object_uses: vec![],
1819                has_cjs_exports: false,
1820            },
1821            ResolvedModule {
1822                file_id: FileId(3),
1823                path: PathBuf::from("/project/source.ts"),
1824                exports: vec![crate::extract::ExportInfo {
1825                    name: ExportName::Named("foo".to_string()),
1826                    local_name: Some("foo".to_string()),
1827                    is_type_only: false,
1828                    span: oxc_span::Span::new(0, 20),
1829                    members: vec![],
1830                }],
1831                re_exports: vec![],
1832                resolved_imports: vec![],
1833                resolved_dynamic_imports: vec![],
1834                resolved_dynamic_patterns: vec![],
1835                member_accesses: vec![],
1836                whole_object_uses: vec![],
1837                has_cjs_exports: false,
1838            },
1839        ];
1840
1841        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
1842
1843        // All modules in the chain should have foo referenced
1844        let barrel1 = &graph.modules[1];
1845        let b1_foo = barrel1
1846            .exports
1847            .iter()
1848            .find(|e| e.name.to_string() == "foo")
1849            .unwrap();
1850        assert!(
1851            !b1_foo.references.is_empty(),
1852            "barrel1's foo should be referenced"
1853        );
1854
1855        let barrel2 = &graph.modules[2];
1856        let b2_foo = barrel2
1857            .exports
1858            .iter()
1859            .find(|e| e.name.to_string() == "foo")
1860            .unwrap();
1861        assert!(
1862            !b2_foo.references.is_empty(),
1863            "barrel2's foo should be referenced (propagated through chain)"
1864        );
1865
1866        let source = &graph.modules[3];
1867        let src_foo = source
1868            .exports
1869            .iter()
1870            .find(|e| e.name.to_string() == "foo")
1871            .unwrap();
1872        assert!(
1873            !src_foo.references.is_empty(),
1874            "source's foo should be referenced (propagated through 2-level chain)"
1875        );
1876    }
1877}