use rustc_hash::{FxHashMap, FxHashSet};
use fallow_config::ResolvedConfig;
use crate::discover::FileId;
use crate::graph::{ModuleGraph, ModuleNode};
use crate::results::{
DuplicateExport, DuplicateLocation, ExportUsage, ReferenceLocation, StaleSuppression,
SuppressionOrigin, UnusedExport,
};
use crate::suppress::{IssueKind, SuppressionContext};
use super::{LineOffsetsMap, byte_offset_to_line_col, read_source};
type IgnoreMatchers<'a> = Vec<(globset::GlobMatcher, &'a [String])>;
type PluginMatchers<'a> = Vec<CompiledUsedExportRule<'a>>;
fn compile_ignore_matchers(config: &ResolvedConfig) -> IgnoreMatchers<'_> {
config
.ignore_export_rules
.iter()
.filter_map(|rule| match globset::Glob::new(&rule.file) {
Ok(g) => Some((g.compile_matcher(), rule.exports.as_slice())),
Err(e) => {
tracing::warn!("invalid ignoreExports pattern '{}': {e}", rule.file);
None
}
})
.collect()
}
fn compile_plugin_matchers(
plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
) -> PluginMatchers<'_> {
let Some(pr) = plugin_result else {
return Vec::new();
};
pr.used_exports
.iter()
.filter_map(compile_used_export_rule)
.collect()
}
struct CompiledUsedExportRule<'a> {
path: crate::plugins::CompiledPathRule,
exports: Vec<&'a str>,
}
impl CompiledUsedExportRule<'_> {
fn matches(&self, path: &str) -> bool {
self.path.matches(path)
}
}
fn compile_used_export_rule(
rule: &crate::plugins::PluginUsedExportRule,
) -> Option<CompiledUsedExportRule<'_>> {
Some(CompiledUsedExportRule {
path: crate::plugins::CompiledPathRule::for_used_export_rule(
&rule.rule.path,
"used_exports pattern",
)?,
exports: rule.rule.exports.iter().map(String::as_str).collect(),
})
}
fn should_skip_module(
module: &ModuleNode,
has_plugin_used_exports: bool,
include_entry_exports: bool,
) -> bool {
if module.is_entry_point() && !has_plugin_used_exports && !include_entry_exports {
return true;
}
if !module.is_reachable() {
return module.exports.iter().all(|e| e.references.is_empty());
}
if module.has_cjs_exports() && module.exports.is_empty() {
return true;
}
module.path.extension().is_some_and(|ext| ext == "svelte")
}
fn is_export_ignored(
export_name: &str,
matching_ignore: &[&[String]],
matching_plugin: &[&[&str]],
) -> bool {
matching_ignore
.iter()
.any(|exports| exports.iter().any(|e| e == "*" || e == export_name))
|| matching_plugin
.iter()
.any(|exports| exports.contains(&export_name))
}
pub fn find_unused_exports(
graph: &ModuleGraph,
config: &ResolvedConfig,
plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
suppressions: &SuppressionContext<'_>,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> (Vec<UnusedExport>, Vec<UnusedExport>, Vec<StaleSuppression>) {
let mut unused_exports = Vec::new();
let mut unused_types = Vec::new();
let mut stale_expected_unused = Vec::new();
let ignore_matchers = compile_ignore_matchers(config);
let plugin_matchers = compile_plugin_matchers(plugin_result);
let reachable_files: FxHashSet<u32> = graph
.modules
.iter()
.filter(|m| m.is_reachable())
.map(|m| m.file_id.0)
.collect();
for module in &graph.modules {
let relative_path = module
.path
.strip_prefix(&config.root)
.unwrap_or(&module.path);
let file_str = relative_path.to_string_lossy();
let matching_ignore: Vec<&[String]> = ignore_matchers
.iter()
.filter(|(m, _)| m.is_match(file_str.as_ref()))
.map(|(_, exports)| *exports)
.collect();
let matching_plugin: Vec<&[&str]> = plugin_matchers
.iter()
.filter(|rule| rule.matches(file_str.as_ref()))
.map(|rule| rule.exports.as_slice())
.collect();
if should_skip_module(
module,
!matching_plugin.is_empty(),
config.include_entry_exports,
) {
continue;
}
let re_export_names: FxHashSet<&str> = module
.re_exports
.iter()
.map(|re| re.exported_name.as_str())
.collect();
for export in &module.exports {
let is_referenced = if module.is_reachable() {
!export.references.is_empty()
} else {
export
.references
.iter()
.any(|r| reachable_files.contains(&r.from_file.0))
};
if matches!(
export.visibility,
fallow_types::extract::VisibilityTag::ExpectedUnused
) {
if is_referenced {
let (line, col) = byte_offset_to_line_col(
line_offsets_by_file,
module.file_id,
export.span.start,
);
stale_expected_unused.push(StaleSuppression {
path: module.path.clone(),
line,
col,
origin: SuppressionOrigin::JsdocTag {
export_name: export.name.to_string(),
},
});
}
continue;
}
if export.visibility.suppresses_unused() || is_referenced {
continue;
}
let export_str = export.name.to_string();
if is_export_ignored(&export_str, &matching_ignore, &matching_plugin) {
continue;
}
let (line, col) =
byte_offset_to_line_col(line_offsets_by_file, module.file_id, export.span.start);
let is_re_export = re_export_names.contains(export_str.as_str());
let issue_kind = if export.is_type_only {
IssueKind::UnusedType
} else {
IssueKind::UnusedExport
};
if suppressions.is_suppressed(module.file_id, line, issue_kind) {
continue;
}
let unused = UnusedExport {
path: module.path.clone(),
export_name: export_str,
is_type_only: export.is_type_only,
line,
col,
span_start: export.span.start,
is_re_export,
};
if export.is_type_only {
unused_types.push(unused);
} else {
unused_exports.push(unused);
}
}
}
(unused_exports, unused_types, stale_expected_unused)
}
pub fn find_duplicate_exports(
graph: &ModuleGraph,
suppressions: &SuppressionContext<'_>,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Vec<DuplicateExport> {
let mut re_export_sources: FxHashMap<usize, FxHashSet<usize>> = FxHashMap::default();
for (idx, module) in graph.modules.iter().enumerate() {
for re in &module.re_exports {
re_export_sources
.entry(idx)
.or_default()
.insert(re.source_file.0 as usize);
}
}
struct ExportEntry {
module_idx: usize,
path: std::path::PathBuf,
file_id: FileId,
span_start: u32,
is_type_only: bool,
}
let mut export_locations: FxHashMap<String, Vec<ExportEntry>> = FxHashMap::default();
for (idx, module) in graph.modules.iter().enumerate() {
if !module.is_reachable() || module.is_entry_point() {
continue;
}
if suppressions.is_file_suppressed(module.file_id, IssueKind::DuplicateExport) {
continue;
}
for export in &module.exports {
if matches!(export.name, crate::extract::ExportName::Default) {
continue; }
if export.span.start == 0 && export.span.end == 0 {
continue;
}
let name = export.name.to_string();
export_locations.entry(name).or_default().push(ExportEntry {
module_idx: idx,
path: module.path.clone(),
file_id: module.file_id,
span_start: export.span.start,
is_type_only: export.is_type_only,
});
}
}
let mut sorted_locations: Vec<_> = export_locations.into_iter().collect();
sorted_locations.sort_by(|a, b| a.0.cmp(&b.0));
sorted_locations
.into_iter()
.filter_map(|(name, locations)| {
if locations.len() <= 1 {
return None;
}
let has_value = locations.iter().any(|e| !e.is_type_only);
let has_type = locations.iter().any(|e| e.is_type_only);
if has_value && has_type {
let value_modules: FxHashSet<usize> = locations
.iter()
.filter(|e| !e.is_type_only)
.map(|e| e.module_idx)
.collect();
let type_modules: FxHashSet<usize> = locations
.iter()
.filter(|e| e.is_type_only)
.map(|e| e.module_idx)
.collect();
if value_modules.len() <= 1 && type_modules.len() <= 1 {
return None;
}
}
let module_indices: FxHashSet<usize> = locations.iter().map(|e| e.module_idx).collect();
let independent: Vec<DuplicateLocation> = locations
.into_iter()
.filter(|e| {
let sources = re_export_sources.get(&e.module_idx);
let has_source_in_set =
sources.is_some_and(|s| s.iter().any(|src| module_indices.contains(src)));
!has_source_in_set
})
.map(|e| {
let (line, col) =
byte_offset_to_line_col(line_offsets_by_file, e.file_id, e.span_start);
DuplicateLocation {
path: e.path,
line,
col,
}
})
.collect();
if independent.len() <= 1 {
return None;
}
let has_shared_importer = has_common_importer(&independent, graph);
if has_shared_importer {
Some(DuplicateExport {
export_name: name,
locations: independent,
})
} else {
None
}
})
.collect()
}
fn has_common_importer(locations: &[DuplicateLocation], graph: &ModuleGraph) -> bool {
if locations.len() <= 1 {
return false;
}
let file_ids: Vec<FileId> = locations
.iter()
.filter_map(|loc| {
graph
.modules
.iter()
.find(|m| m.path == loc.path)
.map(|m| m.file_id)
})
.collect();
if file_ids.len() <= 1 {
return false;
}
for i in 0..file_ids.len() {
let idx_i = file_ids[i].0 as usize;
if idx_i >= graph.reverse_deps.len() {
continue;
}
let importers_i: FxHashSet<FileId> = graph.reverse_deps[idx_i].iter().copied().collect();
for j in (i + 1)..file_ids.len() {
let idx_j = file_ids[j].0 as usize;
if idx_j >= graph.reverse_deps.len() {
continue;
}
if graph.reverse_deps[idx_j]
.iter()
.any(|imp| importers_i.contains(imp))
{
return true;
}
if importers_i.contains(&file_ids[j])
|| graph.reverse_deps[idx_j].contains(&file_ids[i])
{
return true;
}
}
}
false
}
pub fn collect_export_usages(
graph: &ModuleGraph,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Vec<ExportUsage> {
let mut usages = Vec::new();
let file_paths: FxHashMap<FileId, &std::path::Path> = graph
.modules
.iter()
.map(|m| (m.file_id, m.path.as_path()))
.collect();
let mut source_cache: FxHashMap<FileId, (String, Vec<u32>)> = FxHashMap::default();
for module in &graph.modules {
if !module.is_reachable() {
continue;
}
for export in &module.exports {
if export.span.start == 0 && export.span.end == 0 {
continue;
}
let (line, col) =
byte_offset_to_line_col(line_offsets_by_file, module.file_id, export.span.start);
let reference_locations: Vec<ReferenceLocation> = export
.references
.iter()
.filter_map(|r| {
if r.import_span.start == 0 && r.import_span.end == 0 {
return None;
}
let ref_path = file_paths.get(&r.from_file)?;
let (ref_line, ref_col) = if line_offsets_by_file.contains_key(&r.from_file) {
byte_offset_to_line_col(
line_offsets_by_file,
r.from_file,
r.import_span.start,
)
} else {
let (_, offsets) = source_cache.entry(r.from_file).or_insert_with(|| {
let src = read_source(ref_path);
let ofs = fallow_types::extract::compute_line_offsets(&src);
(src, ofs)
});
fallow_types::extract::byte_offset_to_line_col(offsets, r.import_span.start)
};
Some(ReferenceLocation {
path: ref_path.to_path_buf(),
line: ref_line,
col: ref_col,
})
})
.collect();
usages.push(ExportUsage {
path: module.path.clone(),
export_name: export.name.to_string(),
line,
col,
reference_count: export.references.len(),
reference_locations,
});
}
}
usages
}
#[cfg(test)]
mod tests {
use super::*;
use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
use crate::extract::{ExportName, VisibilityTag};
use crate::graph::{ExportSymbol, ModuleGraph, ReExportEdge, SymbolReference};
use crate::resolve::ResolvedModule;
use crate::suppress::Suppression;
use oxc_span::Span;
use std::path::PathBuf;
#[expect(
clippy::cast_possible_truncation,
reason = "test file counts are trivially small"
)]
fn build_graph(file_specs: &[(&str, bool)]) -> ModuleGraph {
let files: Vec<DiscoveredFile> = file_specs
.iter()
.enumerate()
.map(|(i, (path, _))| DiscoveredFile {
id: FileId(i as u32),
path: PathBuf::from(path),
size_bytes: 0,
})
.collect();
let entry_points: Vec<EntryPoint> = file_specs
.iter()
.filter(|(_, is_entry)| *is_entry)
.map(|(path, _)| EntryPoint {
path: PathBuf::from(path),
source: EntryPointSource::ManualEntry,
})
.collect();
let resolved_modules: Vec<ResolvedModule> = files
.iter()
.map(|f| ResolvedModule {
file_id: f.id,
path: f.path.clone(),
exports: vec![],
re_exports: vec![],
resolved_imports: vec![],
resolved_dynamic_imports: vec![],
resolved_dynamic_patterns: vec![],
member_accesses: vec![],
whole_object_uses: vec![],
has_cjs_exports: false,
unused_import_bindings: FxHashSet::default(),
})
.collect();
ModuleGraph::build(&resolved_modules, &entry_points, &files)
}
fn test_config() -> ResolvedConfig {
fallow_config::FallowConfig::default().resolve(
PathBuf::from("/tmp/test"),
fallow_config::OutputFormat::Human,
1,
true,
true,
)
}
fn make_export(name: &str, span_start: u32, span_end: u32) -> ExportSymbol {
ExportSymbol {
name: ExportName::Named(name.to_string()),
is_type_only: false,
visibility: VisibilityTag::None,
span: Span::new(span_start, span_end),
references: vec![],
members: vec![],
}
}
fn make_referenced_export(
name: &str,
span_start: u32,
span_end: u32,
from: u32,
) -> ExportSymbol {
ExportSymbol {
name: ExportName::Named(name.to_string()),
is_type_only: false,
visibility: VisibilityTag::None,
span: Span::new(span_start, span_end),
references: vec![SymbolReference {
from_file: FileId(from),
kind: crate::graph::ReferenceKind::NamedImport,
import_span: Span::new(0, 10),
}],
members: vec![],
}
}
#[test]
fn duplicate_exports_empty_graph() {
let graph = build_graph(&[]);
let suppressions = SuppressionContext::empty();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn duplicate_exports_no_duplicates_single_module() {
let mut graph = build_graph(&[("/src/entry.ts", true), ("/src/utils.ts", false)]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("foo", 10, 20), make_export("bar", 30, 40)];
let suppressions = SuppressionContext::empty();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn duplicate_exports_detects_same_name_in_two_modules() {
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/a.ts", false),
("/src/b.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("helper", 10, 20)];
graph.modules[2].set_reachable(true);
graph.modules[2].exports = vec![make_export("helper", 10, 20)];
graph.reverse_deps[1] = vec![FileId(0)];
graph.reverse_deps[2] = vec![FileId(0)];
let suppressions = SuppressionContext::empty();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert_eq!(result.len(), 1);
assert_eq!(result[0].export_name, "helper");
assert_eq!(result[0].locations.len(), 2);
}
#[test]
fn duplicate_exports_skips_default_exports() {
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/a.ts", false),
("/src/b.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![ExportSymbol {
name: ExportName::Default,
is_type_only: false,
visibility: VisibilityTag::None,
span: Span::new(10, 20),
references: vec![],
members: vec![],
}];
graph.modules[2].set_reachable(true);
graph.modules[2].exports = vec![ExportSymbol {
name: ExportName::Default,
is_type_only: false,
visibility: VisibilityTag::None,
span: Span::new(10, 20),
references: vec![],
members: vec![],
}];
let suppressions = SuppressionContext::empty();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn duplicate_exports_skips_synthetic_re_export_entries() {
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/a.ts", false),
("/src/b.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("helper", 0, 0)]; graph.modules[2].set_reachable(true);
graph.modules[2].exports = vec![make_export("helper", 10, 20)]; let suppressions = SuppressionContext::empty();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn duplicate_exports_skips_unreachable_modules() {
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/a.ts", false),
("/src/b.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("helper", 10, 20)];
graph.modules[2].exports = vec![make_export("helper", 10, 20)];
let suppressions = SuppressionContext::empty();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn duplicate_exports_skips_entry_points() {
let mut graph = build_graph(&[("/src/entry.ts", true), ("/src/b.ts", false)]);
graph.modules[0].exports = vec![make_export("helper", 10, 20)];
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("helper", 10, 20)];
let suppressions = SuppressionContext::empty();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn duplicate_exports_filters_re_export_chains() {
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/index.ts", false),
("/src/helper.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("helper", 10, 20)];
graph.modules[1].re_exports = vec![ReExportEdge {
source_file: FileId(2),
imported_name: "helper".to_string(),
exported_name: "helper".to_string(),
is_type_only: false,
span: oxc_span::Span::default(),
}];
graph.modules[2].set_reachable(true);
graph.modules[2].exports = vec![make_export("helper", 5, 15)];
let suppressions = SuppressionContext::empty();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn duplicate_exports_suppressed_file_wide() {
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/a.ts", false),
("/src/b.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("helper", 10, 20)];
graph.modules[2].set_reachable(true);
graph.modules[2].exports = vec![make_export("helper", 10, 20)];
let supp = vec![Suppression {
line: 0,
comment_line: 1,
kind: Some(IssueKind::DuplicateExport),
}];
let mut supp_map: FxHashMap<FileId, &[Suppression]> = FxHashMap::default();
supp_map.insert(FileId(2), &supp);
let suppressions = SuppressionContext::from_map(supp_map);
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn duplicate_exports_three_modules_same_name() {
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/a.ts", false),
("/src/b.ts", false),
("/src/c.ts", false),
]);
for i in 1..=3 {
graph.modules[i].set_reachable(true);
graph.modules[i].exports = vec![make_export("sharedFn", 10, 20)];
}
graph.reverse_deps[1] = vec![FileId(0)];
graph.reverse_deps[2] = vec![FileId(0)];
graph.reverse_deps[3] = vec![FileId(0)];
let suppressions = SuppressionContext::empty();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert_eq!(result.len(), 1);
assert_eq!(result[0].export_name, "sharedFn");
assert_eq!(result[0].locations.len(), 3);
}
#[test]
fn duplicate_exports_unrelated_leaf_files_not_flagged() {
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/routes/foo/page.ts", false),
("/src/routes/bar/page.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("Area", 10, 20)];
graph.modules[2].set_reachable(true);
graph.modules[2].exports = vec![make_export("Area", 10, 20)];
let suppressions = SuppressionContext::empty();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert!(
result.is_empty(),
"unrelated leaf files should not be flagged as duplicates"
);
}
#[test]
fn duplicate_exports_direct_import_still_flagged() {
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/a.ts", false),
("/src/b.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("helper", 10, 20)];
graph.modules[2].set_reachable(true);
graph.modules[2].exports = vec![make_export("helper", 10, 20)];
graph.reverse_deps[2] = vec![FileId(1)];
let suppressions = SuppressionContext::empty();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert_eq!(
result.len(),
1,
"directly connected files should still be flagged"
);
}
#[test]
fn duplicate_exports_different_names_not_duplicated() {
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/a.ts", false),
("/src/b.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("foo", 10, 20)];
graph.modules[2].set_reachable(true);
graph.modules[2].exports = vec![make_export("bar", 10, 20)];
let suppressions = SuppressionContext::empty();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn duplicate_exports_value_type_merging_not_flagged() {
let mut graph = build_graph(&[("/src/entry.ts", true), ("/src/schema.ts", false)]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![
make_export("Status", 10, 20), make_type_export("Status", 50, 60), ];
graph.reverse_deps[1] = vec![FileId(0)];
let suppressions = SuppressionContext::empty();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert!(
result.is_empty(),
"value+type merging should not be flagged as duplicate"
);
}
#[test]
fn duplicate_exports_value_type_cross_file_not_flagged() {
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/a.ts", false),
("/src/b.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("Status", 10, 20)];
graph.modules[2].set_reachable(true);
graph.modules[2].exports = vec![make_type_export("Status", 10, 20)];
graph.reverse_deps[1] = vec![FileId(0)];
graph.reverse_deps[2] = vec![FileId(0)];
let suppressions = SuppressionContext::empty();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert!(
result.is_empty(),
"cross-file value+type should not be flagged"
);
}
#[test]
fn duplicate_exports_same_namespace_still_flagged() {
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/a.ts", false),
("/src/b.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("helper", 10, 20)];
graph.modules[2].set_reachable(true);
graph.modules[2].exports = vec![make_export("helper", 10, 20)];
graph.reverse_deps[1] = vec![FileId(0)];
graph.reverse_deps[2] = vec![FileId(0)];
let suppressions = SuppressionContext::empty();
let result = find_duplicate_exports(&graph, &suppressions, &FxHashMap::default());
assert_eq!(
result.len(),
1,
"same-namespace duplicates should still be flagged"
);
}
fn test_config_with_ignore_exports(
rules: Vec<fallow_config::IgnoreExportRule>,
) -> ResolvedConfig {
fallow_config::FallowConfig {
ignore_exports: rules,
..Default::default()
}
.resolve(
PathBuf::from("/tmp/test"),
fallow_config::OutputFormat::Human,
1,
true,
true,
)
}
fn make_plugin_result(
used_exports: Vec<(String, Vec<String>)>,
) -> crate::plugins::AggregatedPluginResult {
crate::plugins::AggregatedPluginResult {
entry_patterns: vec![],
config_patterns: vec![],
always_used: vec![],
used_exports: used_exports
.into_iter()
.map(|(pattern, exports)| {
crate::plugins::PluginUsedExportRule::new(
"test-plugin",
crate::plugins::UsedExportRule::new(pattern, exports),
)
})
.collect(),
used_class_members: vec![],
scss_include_paths: vec![],
entry_point_roles: FxHashMap::default(),
referenced_dependencies: vec![],
discovered_always_used: vec![],
setup_files: vec![],
tooling_dependencies: vec![],
script_used_packages: FxHashSet::default(),
virtual_module_prefixes: vec![],
generated_import_patterns: vec![],
path_aliases: vec![],
active_plugins: vec![],
fixture_patterns: vec![],
}
}
fn make_type_export(name: &str, span_start: u32, span_end: u32) -> ExportSymbol {
ExportSymbol {
name: ExportName::Named(name.to_string()),
is_type_only: true,
visibility: VisibilityTag::None,
span: Span::new(span_start, span_end),
references: vec![],
members: vec![],
}
}
#[test]
fn unused_exports_empty_graph() {
let graph = build_graph(&[]);
let config = test_config();
let suppressions = SuppressionContext::empty();
let (exports, types, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(exports.is_empty());
assert!(types.is_empty());
}
#[test]
fn unused_exports_detects_unreferenced_export() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("helper", 10, 20)];
let config = test_config();
let suppressions = SuppressionContext::empty();
let (exports, types, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert_eq!(exports.len(), 1);
assert_eq!(exports[0].export_name, "helper");
assert!(types.is_empty());
}
#[test]
fn unused_exports_skips_referenced_export() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_referenced_export("helper", 10, 20, 0)];
let config = test_config();
let suppressions = SuppressionContext::empty();
let (exports, types, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(exports.is_empty());
assert!(types.is_empty());
}
#[test]
fn unused_exports_skips_public_export() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![ExportSymbol {
name: ExportName::Named("publicFn".to_string()),
is_type_only: false,
visibility: VisibilityTag::Public,
span: Span::new(10, 20),
references: vec![],
members: vec![],
}];
let config = test_config();
let suppressions = SuppressionContext::empty();
let (exports, types, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(exports.is_empty());
assert!(types.is_empty());
}
#[test]
fn unused_exports_separates_types_from_values() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![
make_export("valueFn", 10, 20),
make_type_export("MyType", 30, 40),
];
let config = test_config();
let suppressions = SuppressionContext::empty();
let (exports, types, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert_eq!(exports.len(), 1);
assert_eq!(exports[0].export_name, "valueFn");
assert_eq!(types.len(), 1);
assert_eq!(types[0].export_name, "MyType");
}
#[test]
fn unused_exports_skips_unreachable_module() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/dead.ts", false),
]);
graph.modules[1].exports = vec![make_export("orphan", 10, 20)];
let config = test_config();
let suppressions = SuppressionContext::empty();
let (exports, types, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(exports.is_empty());
assert!(types.is_empty());
}
#[test]
fn unused_exports_skips_entry_point() {
let mut graph = build_graph(&[("/tmp/test/src/entry.ts", true)]);
graph.modules[0].exports = vec![make_export("main", 10, 20)];
let config = test_config();
let suppressions = SuppressionContext::empty();
let (exports, types, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(exports.is_empty());
assert!(types.is_empty());
}
#[test]
fn unused_exports_reports_non_framework_exports_in_entry_point_with_plugin_rules() {
let mut graph = build_graph(&[("/tmp/test/src/app/page.tsx", true)]);
graph.modules[0].set_reachable(true);
graph.modules[0].exports = vec![
make_export("default", 10, 20),
make_export("generateMetadata", 30, 40),
make_export("helper", 50, 60),
];
let plugin = make_plugin_result(vec![(
"src/app/**/page.{ts,tsx,js,jsx}".to_string(),
vec!["default".to_string(), "generateMetadata".to_string()],
)]);
let config = test_config();
let suppressions = SuppressionContext::empty();
let (exports, types, _stale) = find_unused_exports(
&graph,
&config,
Some(&plugin),
&suppressions,
&FxHashMap::default(),
);
assert_eq!(exports.len(), 1);
assert_eq!(exports[0].export_name, "helper");
assert!(types.is_empty());
}
#[test]
fn unused_exports_skips_cjs_only_module() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/legacy.js", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].set_cjs_exports(true);
graph.modules[1].exports = vec![];
let config = test_config();
let suppressions = SuppressionContext::empty();
let (exports, types, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(exports.is_empty());
assert!(types.is_empty());
}
#[test]
fn unused_exports_does_not_skip_cjs_module_with_named_exports() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/mixed.js", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].set_cjs_exports(true);
graph.modules[1].exports = vec![make_export("namedFn", 10, 20)];
let config = test_config();
let suppressions = SuppressionContext::empty();
let (exports, _, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert_eq!(exports.len(), 1);
assert_eq!(exports[0].export_name, "namedFn");
}
#[test]
fn unused_exports_skips_svelte_files() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/Component.svelte", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("count", 10, 20)];
let config = test_config();
let suppressions = SuppressionContext::empty();
let (exports, types, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(exports.is_empty());
assert!(types.is_empty());
}
#[test]
fn unused_exports_reports_reachable_non_entry_non_cjs_non_svelte() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].set_cjs_exports(false);
graph.modules[1].exports = vec![make_export("helper", 10, 20)];
let config = test_config();
let suppressions = SuppressionContext::empty();
let (exports, _, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert_eq!(exports.len(), 1);
assert_eq!(exports[0].export_name, "helper");
}
#[test]
fn unused_exports_empty_ignore_config() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("foo", 10, 20)];
let config = test_config(); let suppressions = SuppressionContext::empty();
let (exports, _, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert_eq!(
exports.len(),
1,
"no ignore rules, export should be reported"
);
}
#[test]
fn unused_exports_ignore_multiple_patterns() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/types.ts", false),
("/tmp/test/src/constants.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("MyType", 10, 20)];
graph.modules[2].set_reachable(true);
graph.modules[2].exports = vec![make_export("MY_CONST", 10, 20)];
let config = test_config_with_ignore_exports(vec![
fallow_config::IgnoreExportRule {
file: "src/types.ts".to_string(),
exports: vec!["*".to_string()],
},
fallow_config::IgnoreExportRule {
file: "src/constants.ts".to_string(),
exports: vec!["MY_CONST".to_string()],
},
]);
let suppressions = SuppressionContext::empty();
let (exports, _, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(
exports.is_empty(),
"both exports should be ignored by config rules"
);
}
#[test]
fn unused_exports_invalid_ignore_glob_skipped() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("foo", 10, 20)];
let config = test_config_with_ignore_exports(vec![fallow_config::IgnoreExportRule {
file: "[invalid".to_string(),
exports: vec!["*".to_string()],
}]);
let suppressions = SuppressionContext::empty();
let (exports, _, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert_eq!(
exports.len(),
1,
"invalid glob should be skipped, export still reported"
);
}
#[test]
fn unused_exports_ignore_wildcard_matches_all() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/types.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("TypeA", 10, 20), make_export("TypeB", 30, 40)];
let config = test_config_with_ignore_exports(vec![fallow_config::IgnoreExportRule {
file: "src/types.ts".to_string(),
exports: vec!["*".to_string()],
}]);
let suppressions = SuppressionContext::empty();
let (exports, _, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(
exports.is_empty(),
"wildcard * should ignore all exports in matching file"
);
}
#[test]
fn unused_exports_ignore_specific_name_only() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![
make_export("ignored", 10, 20),
make_export("reported", 30, 40),
];
let config = test_config_with_ignore_exports(vec![fallow_config::IgnoreExportRule {
file: "src/utils.ts".to_string(),
exports: vec!["ignored".to_string()],
}]);
let suppressions = SuppressionContext::empty();
let (exports, _, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert_eq!(exports.len(), 1);
assert_eq!(exports[0].export_name, "reported");
}
#[test]
fn unused_exports_ignore_rule_wrong_file_no_effect() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("foo", 10, 20)];
let config = test_config_with_ignore_exports(vec![fallow_config::IgnoreExportRule {
file: "src/other.ts".to_string(),
exports: vec!["*".to_string()],
}]);
let suppressions = SuppressionContext::empty();
let (exports, _, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert_eq!(
exports.len(),
1,
"ignore rule for different file should not suppress"
);
}
#[test]
fn unused_exports_no_plugin_result() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("foo", 10, 20)];
let config = test_config();
let suppressions = SuppressionContext::empty();
let (exports, _, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert_eq!(
exports.len(),
1,
"None plugin_result means no plugin matchers"
);
}
#[test]
fn unused_exports_plugin_no_used_exports() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("foo", 10, 20)];
let config = test_config();
let suppressions = SuppressionContext::empty();
let pr = make_plugin_result(vec![]);
let (exports, _, _stale) = find_unused_exports(
&graph,
&config,
Some(&pr),
&suppressions,
&FxHashMap::default(),
);
assert_eq!(
exports.len(),
1,
"plugin with no used_exports should not suppress"
);
}
#[test]
fn unused_exports_plugin_used_exports_suppresses() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/pages/index.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![
make_export("getStaticProps", 10, 20),
make_export("unusedHelper", 30, 40),
];
let config = test_config();
let suppressions = SuppressionContext::empty();
let pr = make_plugin_result(vec![(
"src/pages/**".to_string(),
vec!["getStaticProps".to_string()],
)]);
let (exports, _, _stale) = find_unused_exports(
&graph,
&config,
Some(&pr),
&suppressions,
&FxHashMap::default(),
);
assert_eq!(exports.len(), 1);
assert_eq!(exports[0].export_name, "unusedHelper");
}
#[test]
fn unused_exports_both_config_and_plugin_ignore() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/api/handler.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("handler", 10, 20)];
let config = test_config_with_ignore_exports(vec![fallow_config::IgnoreExportRule {
file: "src/api/*.ts".to_string(),
exports: vec!["handler".to_string()],
}]);
let suppressions = SuppressionContext::empty();
let pr = make_plugin_result(vec![(
"src/api/**".to_string(),
vec!["handler".to_string()],
)]);
let (exports, _, _stale) = find_unused_exports(
&graph,
&config,
Some(&pr),
&suppressions,
&FxHashMap::default(),
);
assert!(
exports.is_empty(),
"export matching both config and plugin should be ignored"
);
}
#[test]
fn unused_exports_invalid_plugin_glob_skipped() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("foo", 10, 20)];
let config = test_config();
let suppressions = SuppressionContext::empty();
let pr = make_plugin_result(vec![("[invalid".to_string(), vec!["foo".to_string()])]);
let (exports, _, _stale) = find_unused_exports(
&graph,
&config,
Some(&pr),
&suppressions,
&FxHashMap::default(),
);
assert_eq!(exports.len(), 1, "invalid plugin glob should be skipped");
}
#[test]
fn unused_exports_marks_re_export_semantically() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/barrel.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("reexported", 100, 120)];
graph.modules[1].re_exports = vec![ReExportEdge {
source_file: FileId(0),
imported_name: "reexported".to_string(),
exported_name: "reexported".to_string(),
is_type_only: false,
span: oxc_span::Span::default(),
}];
let config = test_config();
let suppressions = SuppressionContext::empty();
let (exports, _, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert_eq!(exports.len(), 1);
assert!(
exports[0].is_re_export,
"export with matching ReExportEdge should be flagged as re-export"
);
assert_eq!(exports[0].span_start, 100);
}
#[test]
fn collect_usages_empty_graph() {
let graph = build_graph(&[]);
let result = collect_export_usages(&graph, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn collect_usages_skips_unreachable_modules() {
let mut graph = build_graph(&[("/src/dead.ts", false)]);
graph.modules[0].exports = vec![make_export("unused", 10, 20)];
let result = collect_export_usages(&graph, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn collect_usages_skips_synthetic_exports() {
let mut graph = build_graph(&[("/src/barrel.ts", true)]);
graph.modules[0].exports = vec![make_export("reexported", 0, 0)];
let result = collect_export_usages(&graph, &FxHashMap::default());
assert!(result.is_empty());
}
#[test]
fn collect_usages_counts_references() {
let mut graph = build_graph(&[("/src/utils.ts", true), ("/src/app.ts", false)]);
graph.modules[0].exports = vec![make_referenced_export("helper", 10, 20, 1)];
let result = collect_export_usages(&graph, &FxHashMap::default());
assert_eq!(result.len(), 1);
assert_eq!(result[0].export_name, "helper");
assert_eq!(result[0].reference_count, 1);
}
#[test]
fn collect_usages_zero_references_still_reported() {
let mut graph = build_graph(&[("/src/utils.ts", true)]);
graph.modules[0].exports = vec![make_export("unused", 10, 20)];
let result = collect_export_usages(&graph, &FxHashMap::default());
assert_eq!(result.len(), 1);
assert_eq!(result[0].export_name, "unused");
assert_eq!(result[0].reference_count, 0);
assert!(result[0].reference_locations.is_empty());
}
#[test]
fn collect_usages_multiple_exports_same_module() {
let mut graph = build_graph(&[("/src/utils.ts", true)]);
graph.modules[0].exports = vec![make_export("alpha", 10, 20), make_export("beta", 30, 40)];
let result = collect_export_usages(&graph, &FxHashMap::default());
assert_eq!(result.len(), 2);
let names: FxHashSet<&str> = result.iter().map(|u| u.export_name.as_str()).collect();
assert!(names.contains("alpha"));
assert!(names.contains("beta"));
}
#[test]
fn unused_exports_checks_unreachable_module_with_mixed_references() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/helpers.ts", false),
("/tmp/test/src/setup.ts", false),
]);
graph.modules[1].exports = vec![
ExportSymbol {
name: ExportName::Named("usedByUnreachable".to_string()),
is_type_only: false,
visibility: VisibilityTag::None,
span: Span::new(10, 30),
references: vec![SymbolReference {
from_file: FileId(2), kind: crate::graph::ReferenceKind::NamedImport,
import_span: Span::new(0, 10),
}],
members: vec![],
},
make_export("totallyUnused", 40, 55),
];
graph.modules[2].exports = vec![];
let config = test_config();
let suppressions = SuppressionContext::empty();
let (exports, types, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
let names: FxHashSet<&str> = exports.iter().map(|e| e.export_name.as_str()).collect();
assert!(
names.contains("usedByUnreachable"),
"reference from unreachable module should not save an export"
);
assert!(
names.contains("totallyUnused"),
"completely unreferenced export should be flagged"
);
assert_eq!(exports.len(), 2);
assert!(types.is_empty());
}
#[test]
fn unused_exports_skips_export_referenced_by_reachable() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/helpers.ts", false),
]);
graph.modules[1].exports = vec![ExportSymbol {
name: ExportName::Named("usedByReachable".to_string()),
is_type_only: false,
visibility: VisibilityTag::None,
span: Span::new(10, 28),
references: vec![SymbolReference {
from_file: FileId(0), kind: crate::graph::ReferenceKind::NamedImport,
import_span: Span::new(0, 10),
}],
members: vec![],
}];
let config = test_config();
let suppressions = SuppressionContext::empty();
let (exports, types, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(
exports.is_empty(),
"export referenced by reachable module should not be flagged"
);
assert!(types.is_empty());
}
#[test]
fn unused_exports_skips_internal_visibility() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![ExportSymbol {
name: ExportName::Named("internalHelper".to_string()),
is_type_only: false,
visibility: VisibilityTag::Internal,
span: Span::new(10, 30),
references: vec![],
members: vec![],
}];
let config = test_config();
let suppressions = SuppressionContext::empty();
let (exports, types, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(
exports.is_empty(),
"@internal export should not be flagged as unused"
);
assert!(types.is_empty());
}
#[test]
fn unused_exports_skips_beta_visibility() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![ExportSymbol {
name: ExportName::Named("betaFeature".to_string()),
is_type_only: false,
visibility: VisibilityTag::Beta,
span: Span::new(10, 30),
references: vec![],
members: vec![],
}];
let config = test_config();
let suppressions = SuppressionContext::empty();
let (exports, types, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(
exports.is_empty(),
"@beta export should not be flagged as unused"
);
assert!(types.is_empty());
}
#[test]
fn unused_exports_skips_alpha_visibility() {
let mut graph = build_graph(&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![ExportSymbol {
name: ExportName::Named("alphaFeature".to_string()),
is_type_only: false,
visibility: VisibilityTag::Alpha,
span: Span::new(10, 30),
references: vec![],
members: vec![],
}];
let config = test_config();
let suppressions = SuppressionContext::empty();
let (exports, types, _stale) =
find_unused_exports(&graph, &config, None, &suppressions, &FxHashMap::default());
assert!(
exports.is_empty(),
"@alpha export should not be flagged as unused"
);
assert!(types.is_empty());
}
#[test]
fn unused_exports_include_entry_exports_flag() {
let mut graph = build_graph(&[("/tmp/test/src/entry.ts", true)]);
graph.modules[0].exports = vec![make_export("main", 10, 20)];
let config_off = test_config();
assert!(!config_off.include_entry_exports);
let suppressions = SuppressionContext::empty();
let (exports_off, _, _stale) = find_unused_exports(
&graph,
&config_off,
None,
&suppressions,
&FxHashMap::default(),
);
assert!(
exports_off.is_empty(),
"entry export should be skipped when include_entry_exports is false"
);
let mut config_on = test_config();
config_on.include_entry_exports = true;
let (exports_on, _, _stale) = find_unused_exports(
&graph,
&config_on,
None,
&suppressions,
&FxHashMap::default(),
);
assert_eq!(
exports_on.len(),
1,
"entry export should be flagged when include_entry_exports is true"
);
assert_eq!(exports_on[0].export_name, "main");
}
}