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