use std::sync::LazyLock;
use rayon::prelude::*;
use rustc_hash::{FxHashMap, FxHashSet};
use fallow_config::{CompiledIgnoreExportRule, ResolvedConfig, Severity};
use fallow_types::extract::{ExportInfo, ExportName, ModuleInfo};
use crate::discover::FileId;
use crate::graph::{
AmbiguityParticipants, EffectiveExportResolution, ExportNamespace, ExportSymbol, ModuleGraph,
ModuleNode,
};
use crate::resolve::{has_react_native_plugin, platform_family_key};
use crate::results::{
DuplicateExport, DuplicateLocation, ExportUsage, PrivateTypeLeak, ReferenceLocation,
StaleSuppression, SuppressionOrigin, UnusedExport,
};
use crate::suppress::{IssueKind, SuppressionContext};
use super::{LineOffsetsMap, byte_offset_to_line_col, read_source};
type PluginMatchers<'a> = Vec<CompiledUsedExportRule<'a>>;
const TANSTACK_ROUTER_PLUGIN_NAME: &str = "tanstack-router";
type UnusedExportModuleResult = (Vec<UnusedExport>, Vec<UnusedExport>, Vec<StaleSuppression>);
struct UnusedExportModuleContext<'a> {
graph: &'a ModuleGraph,
config: &'a ResolvedConfig,
ignore_matchers: &'a [CompiledIgnoreExportRule],
plugin_matchers: &'a PluginMatchers<'a>,
module_info_by_id: Option<&'a FxHashMap<FileId, &'a ModuleInfo>>,
suppressions: &'a SuppressionContext<'a>,
line_offsets_by_file: &'a LineOffsetsMap<'a>,
ambiguity: &'a AmbiguityParticipants,
}
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()
}
fn compile_tanstack_duplicate_export_matchers(
plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
) -> PluginMatchers<'_> {
let Some(pr) = plugin_result else {
return Vec::new();
};
pr.used_exports
.iter()
.filter(|rule| rule.plugin_name == TANSTACK_ROUTER_PLUGIN_NAME)
.filter_map(compile_used_export_rule)
.collect()
}
fn is_tanstack_router_active(
plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
) -> bool {
plugin_result.is_some_and(|pr| {
pr.active_plugins
.iter()
.any(|plugin| plugin == TANSTACK_ROUTER_PLUGIN_NAME)
})
}
fn is_react_native_active(plugin_result: Option<&crate::plugins::AggregatedPluginResult>) -> bool {
plugin_result.is_some_and(|pr| has_react_native_plugin(&pr.active_plugins))
}
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 matchers_for_module<'a>(
module_path: &std::path::Path,
config_root: &std::path::Path,
ignore_matchers: &'a [CompiledIgnoreExportRule],
plugin_matchers: &'a PluginMatchers<'_>,
) -> (Vec<&'a [String]>, Vec<&'a [&'a str]>) {
if ignore_matchers.is_empty() && plugin_matchers.is_empty() {
return (Vec::new(), Vec::new());
}
let relative_path = module_path.strip_prefix(config_root).unwrap_or(module_path);
let file_str = relative_path.to_string_lossy();
let mi: Vec<&[String]> = ignore_matchers
.iter()
.filter(|rule| rule.matcher.is_match(file_str.as_ref()))
.map(|rule| rule.exports.as_slice())
.collect();
let mp: Vec<&[&str]> = plugin_matchers
.iter()
.filter(|rule| rule.matches(file_str.as_ref()))
.map(|rule| rule.exports.as_slice())
.collect();
(mi, mp)
}
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(&"*") || exports.contains(&export_name))
}
fn local_export_binding_name(export: &ExportInfo) -> Option<&str> {
export.local_name.as_deref().or(match &export.name {
ExportName::Named(name) => Some(name.as_str()),
ExportName::Default => None,
})
}
fn is_js_like_source(path: &std::path::Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| {
matches!(
ext,
"js" | "jsx" | "mjs" | "cjs" | "ts" | "tsx" | "mts" | "cts"
)
})
}
fn has_route_path_segment(module_path: &std::path::Path, config_root: &std::path::Path) -> bool {
let relative_path = module_path.strip_prefix(config_root).unwrap_or(module_path);
relative_path
.to_string_lossy()
.replace('\\', "/")
.split('/')
.any(|segment| segment == "routes")
}
fn is_route_tree_generated_file(path: &std::path::Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("routeTree.gen."))
}
fn is_route_referenced_by_tanstack_generated_tree(
export_name: &str,
export: &ExportSymbol,
module: &ModuleNode,
graph: &ModuleGraph,
config_root: &std::path::Path,
) -> bool {
export_name == "Route"
&& has_route_path_segment(&module.path, config_root)
&& export.references.iter().any(|reference| {
graph
.modules
.get(reference.from_file.0 as usize)
.is_some_and(|from| is_route_tree_generated_file(&from.path))
})
}
fn collect_exports_used_in_file(module: &ModuleInfo, path: &std::path::Path) -> FxHashSet<String> {
if module.exports.is_empty() || !is_js_like_source(path) {
return FxHashSet::default();
}
let source = read_source(path);
if source.is_empty() {
return FxHashSet::default();
}
let source_type = oxc_span::SourceType::from_path(path).unwrap_or_default();
let allocator = oxc_allocator::Allocator::default();
let parser_return = oxc_parser::Parser::new(&allocator, &source, source_type).parse();
let semantic_ret = oxc_semantic::SemanticBuilder::new().build(&parser_return.program);
let semantic = semantic_ret.semantic;
let scoping = semantic.scoping();
let nodes = semantic.nodes();
let root_scope = scoping.root_scope_id();
let mut used = FxHashSet::default();
for export in module.exports.iter() {
let Some(local_name) = local_export_binding_name(export) else {
continue;
};
let name = oxc_str::Ident::from(local_name);
let Some(symbol_id) = scoping.get_binding(root_scope, name) else {
continue;
};
let has_real_use = scoping
.get_resolved_references(symbol_id)
.any(|reference| !is_inside_export_specifier(nodes, reference.node_id()));
if has_real_use {
used.insert(export.name.to_string());
}
}
used
}
fn is_inside_export_specifier(
nodes: &oxc_semantic::AstNodes<'_>,
node_id: oxc_syntax::node::NodeId,
) -> bool {
nodes
.ancestor_kinds(node_id)
.any(|kind| matches!(kind, oxc_ast::AstKind::ExportSpecifier(_)))
|| matches!(
nodes.parent_kind(node_id),
oxc_ast::AstKind::ExportDefaultDeclaration(_)
)
}
fn ignore_matchers_for_module<'a>(
module_path: &std::path::Path,
config_root: &std::path::Path,
ignore_matchers: &'a [CompiledIgnoreExportRule],
) -> Vec<&'a [String]> {
if ignore_matchers.is_empty() {
return Vec::new();
}
let relative_path = module_path.strip_prefix(config_root).unwrap_or(module_path);
let file_str = relative_path.to_string_lossy();
ignore_matchers
.iter()
.filter(|rule| rule.matcher.is_match(file_str.as_ref()))
.map(|rule| rule.exports.as_slice())
.collect()
}
fn export_has_reachable_reference(
graph: &ModuleGraph,
module: &ModuleNode,
export: &ExportSymbol,
) -> bool {
if module.is_reachable() {
return !export.references.is_empty();
}
export.references.iter().any(|r| {
graph.modules.get(r.from_file.0 as usize).is_some_and(|m| {
debug_assert_eq!(
m.file_id, r.from_file,
"ModuleGraph::modules FileId-as-index invariant broken"
);
m.is_reachable()
})
})
}
fn stale_expected_unused_suppression(
module: &ModuleNode,
export: &ExportSymbol,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> StaleSuppression {
let (line, col) =
byte_offset_to_line_col(line_offsets_by_file, module.file_id, export.span.start);
StaleSuppression {
path: module.path.clone(),
line,
col,
origin: SuppressionOrigin::JsdocTag {
export_name: export.name.to_string(),
reason: export.expected_unused_reason.clone(),
},
missing_reason: false,
actions: StaleSuppression::actions_for(false),
}
}
fn record_expected_unused_stale(
module: &ModuleNode,
export: &ExportSymbol,
ctx: &UnusedExportModuleContext<'_>,
is_referenced: bool,
stale_expected_unused: &mut Vec<StaleSuppression>,
) {
if ctx.config.rules.require_suppression_reason != Severity::Off
&& export.expected_unused_reason.is_none()
{
let (line, col) =
byte_offset_to_line_col(ctx.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(),
reason: None,
},
missing_reason: true,
actions: StaleSuppression::actions_for(true),
});
}
if is_referenced {
stale_expected_unused.push(stale_expected_unused_suppression(
module,
export,
ctx.line_offsets_by_file,
));
}
}
#[deprecated(
since = "2.76.0",
note = "fallow_core is internal; use fallow_api::run_dead_code for typed output; serialize with fallow_api::serialize_dead_code_programmatic_json for JSON output. See docs/fallow-core-migration.md."
)]
pub fn find_unused_exports(
graph: &ModuleGraph,
modules: &[ModuleInfo],
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 = config.compiled_ignore_exports.as_slice();
let plugin_matchers = compile_plugin_matchers(plugin_result);
let module_info_by_id: Option<FxHashMap<FileId, &ModuleInfo>> =
if config.ignore_exports_used_in_file.is_enabled() {
Some(modules.iter().map(|m| (m.file_id, m)).collect())
} else {
None
};
let ambiguity = graph.ambiguity_participants();
let module_context = UnusedExportModuleContext {
graph,
config,
ignore_matchers,
plugin_matchers: &plugin_matchers,
module_info_by_id: module_info_by_id.as_ref(),
suppressions,
line_offsets_by_file,
ambiguity: &ambiguity,
};
let module_results: Vec<UnusedExportModuleResult> = graph
.modules
.par_iter()
.map(|module| find_unused_exports_for_module(module, &module_context))
.collect();
for (exports, types, stale_expected) in module_results {
unused_exports.extend(exports);
unused_types.extend(types);
stale_expected_unused.extend(stale_expected);
}
(unused_exports, unused_types, stale_expected_unused)
}
fn find_unused_exports_for_module(
module: &ModuleNode,
ctx: &UnusedExportModuleContext<'_>,
) -> UnusedExportModuleResult {
let mut stale_expected_unused = Vec::new();
if module.exports.is_empty() && !module.has_cjs_exports() {
return (Vec::new(), Vec::new(), stale_expected_unused);
}
let (matching_ignore, matching_plugin) = matchers_for_module(
&module.path,
&ctx.config.root,
ctx.ignore_matchers,
ctx.plugin_matchers,
);
if should_skip_module(
module,
!matching_plugin.is_empty(),
ctx.config.include_entry_exports,
) {
return (Vec::new(), Vec::new(), stale_expected_unused);
}
let same_file_used_exports = same_file_used_exports(module, ctx);
let re_export_names: FxHashSet<&str> = module
.re_exports
.iter()
.map(|re| re.exported_name.as_str())
.collect();
let mut match_ctx = UnusedExportMatchContext {
same_file_used_exports: &same_file_used_exports,
re_export_names: &re_export_names,
matching_ignore: &matching_ignore,
matching_plugin: &matching_plugin,
stale_expected_unused: &mut stale_expected_unused,
};
let (unused_exports, unused_types) =
collect_module_unused_export_findings(module, ctx, &mut match_ctx);
(unused_exports, unused_types, stale_expected_unused)
}
fn collect_module_unused_export_findings(
module: &ModuleNode,
ctx: &UnusedExportModuleContext<'_>,
match_ctx: &mut UnusedExportMatchContext<'_>,
) -> (Vec<UnusedExport>, Vec<UnusedExport>) {
let mut unused_exports = Vec::new();
let mut unused_types = Vec::new();
for export in &module.exports {
if let Some(unused) = unused_export_for_module(
module,
export,
ctx,
UnusedExportMatchContext {
same_file_used_exports: match_ctx.same_file_used_exports,
re_export_names: match_ctx.re_export_names,
matching_ignore: match_ctx.matching_ignore,
matching_plugin: match_ctx.matching_plugin,
stale_expected_unused: &mut *match_ctx.stale_expected_unused,
},
) {
if export.is_type_only {
unused_types.push(unused);
} else {
unused_exports.push(unused);
}
}
}
(unused_exports, unused_types)
}
fn same_file_used_exports(
module: &ModuleNode,
ctx: &UnusedExportModuleContext<'_>,
) -> FxHashSet<String> {
ctx.module_info_by_id
.map_or_else(FxHashSet::default, |by_id| {
by_id
.get(&module.file_id)
.map_or_else(FxHashSet::default, |info| {
collect_exports_used_in_file(info, &module.path)
})
})
}
struct UnusedExportMatchContext<'a> {
same_file_used_exports: &'a FxHashSet<String>,
re_export_names: &'a FxHashSet<&'a str>,
matching_ignore: &'a [&'a [String]],
matching_plugin: &'a [&'a [&'a str]],
stale_expected_unused: &'a mut Vec<StaleSuppression>,
}
fn unused_export_for_module(
module: &ModuleNode,
export: &ExportSymbol,
ctx: &UnusedExportModuleContext<'_>,
match_ctx: UnusedExportMatchContext<'_>,
) -> Option<UnusedExport> {
let mut match_ctx = match_ctx;
let export_str = export.name.to_string();
if unused_export_candidate_is_skipped(module, export, ctx, &export_str, &mut match_ctx) {
return None;
}
let (line, col) =
byte_offset_to_line_col(ctx.line_offsets_by_file, module.file_id, export.span.start);
let issue_kind = if export.is_type_only {
IssueKind::UnusedType
} else {
IssueKind::UnusedExport
};
if unused_export_suppressed(ctx, module.file_id, line, issue_kind) {
return None;
}
let is_re_export = match_ctx.re_export_names.contains(export_str.as_str());
Some(build_unused_export(
module,
export,
export_str,
line,
col,
is_re_export,
))
}
fn unused_export_candidate_is_skipped(
module: &ModuleNode,
export: &ExportSymbol,
ctx: &UnusedExportModuleContext<'_>,
export_str: &str,
match_ctx: &mut UnusedExportMatchContext<'_>,
) -> bool {
let has_cross_file_ref = export_has_reachable_reference(ctx.graph, module, export);
let is_referenced = has_cross_file_ref || (module.is_reachable() && export.is_side_effect_used);
if matches!(
export.visibility,
fallow_types::extract::VisibilityTag::ExpectedUnused
) {
record_expected_unused_stale(
module,
export,
ctx,
is_referenced,
match_ctx.stale_expected_unused,
);
return true;
}
if export.visibility.suppresses_unused() || is_referenced {
return true;
}
if ctx
.ambiguity
.contains_declaration(module.file_id, export_str, export.is_type_only)
{
return true;
}
if ctx
.config
.ignore_exports_used_in_file
.suppresses(export.is_type_only)
&& match_ctx.same_file_used_exports.contains(export_str)
{
return true;
}
is_export_ignored(
export_str,
match_ctx.matching_ignore,
match_ctx.matching_plugin,
)
}
fn unused_export_suppressed(
ctx: &UnusedExportModuleContext<'_>,
file_id: FileId,
line: u32,
issue_kind: IssueKind,
) -> bool {
ctx.suppressions.is_suppressed(file_id, line, issue_kind)
}
fn build_unused_export(
module: &ModuleNode,
export: &ExportSymbol,
export_name: String,
line: u32,
col: u32,
is_re_export: bool,
) -> UnusedExport {
UnusedExport {
path: module.path.clone(),
export_name,
is_type_only: export.is_type_only,
line,
col,
span_start: export.span.start,
is_re_export,
}
}
pub fn suppress_signature_backing_types(
unused_types: &mut Vec<UnusedExport>,
graph: &ModuleGraph,
modules: &[fallow_types::extract::ModuleInfo],
) {
let path_by_id: FxHashMap<FileId, &std::path::Path> = graph
.modules
.iter()
.map(|module| (module.file_id, module.path.as_path()))
.collect();
let backing_types: FxHashSet<(std::path::PathBuf, String)> = modules
.iter()
.filter_map(|module| path_by_id.get(&module.file_id).map(|path| (module, *path)))
.flat_map(|(module, path)| {
module
.public_signature_type_references
.iter()
.map(move |reference| (path.to_path_buf(), reference.type_name.clone()))
})
.collect();
unused_types.retain(|unused| {
!backing_types.contains(&(unused.path.clone(), unused.export_name.clone()))
});
}
const STORYBOOK_SUFFIXES: &[&str] = &[
".stories.ts",
".stories.tsx",
".stories.js",
".stories.jsx",
".stories.mts",
".stories.cts",
".stories.mjs",
".stories.cjs",
".story.ts",
".story.tsx",
".story.js",
".story.jsx",
".story.mts",
".story.cts",
".story.mjs",
".story.cjs",
];
fn is_storybook_file(path: &std::path::Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
STORYBOOK_SUFFIXES
.iter()
.any(|suffix| name.ends_with(suffix))
})
}
const ROUTE_CONVENTION_PATTERNS: &[&str] = &[
"**/app/**/page.{ts,tsx,js,jsx}",
"**/app/**/layout.{ts,tsx,js,jsx}",
"**/app/**/template.{ts,tsx,js,jsx}",
"**/app/**/loading.{ts,tsx,js,jsx}",
"**/app/**/error.{ts,tsx,js,jsx}",
"**/app/**/not-found.{ts,tsx,js,jsx}",
"**/app/**/route.{ts,tsx,js,jsx}",
"**/app/**/default.{ts,tsx,js,jsx}",
"**/app/**/global-error.{ts,tsx,js,jsx}",
"**/app/**/forbidden.{ts,tsx,js,jsx}",
"**/app/**/unauthorized.{ts,tsx,js,jsx}",
"**/app/global-not-found.{ts,tsx,js,jsx}",
"**/app/page.{ts,tsx,js,jsx}",
"**/app/layout.{ts,tsx,js,jsx}",
"**/app/template.{ts,tsx,js,jsx}",
"**/app/loading.{ts,tsx,js,jsx}",
"**/app/error.{ts,tsx,js,jsx}",
"**/app/not-found.{ts,tsx,js,jsx}",
"**/app/route.{ts,tsx,js,jsx}",
"**/app/default.{ts,tsx,js,jsx}",
"**/app/global-error.{ts,tsx,js,jsx}",
"**/app/**/opengraph-image.{ts,tsx,js,jsx}",
"**/app/**/twitter-image.{ts,tsx,js,jsx}",
"**/app/**/icon.{ts,tsx,js,jsx}",
"**/app/**/apple-icon.{ts,tsx,js,jsx}",
"**/app/**/manifest.{ts,tsx,js,jsx}",
"**/app/**/sitemap.{ts,tsx,js,jsx}",
"**/app/**/robots.{ts,tsx,js,jsx}",
"**/pages/**/*.{ts,tsx,js,jsx}",
"**/src/templates/**/*.{ts,tsx,js,jsx}",
"**/routes/*.{ts,tsx,js,jsx}",
"**/routes/**/route.{ts,tsx,js,jsx}",
"**/routes/**/_layout.{ts,tsx,js,jsx}",
"**/routes/**/_index.{ts,tsx,js,jsx}",
"**/routes/**/index.{ts,tsx,js,jsx}",
"**/routes/**/__root.{ts,tsx,js,jsx}",
"**/app/**/_layout.{ts,tsx,js,jsx}",
"**/app/**/+*.{ts,tsx,js,jsx}",
];
#[expect(
clippy::expect_used,
reason = "route convention globs are hard-coded and covered by entry point tests"
)]
fn build_route_convention_globset() -> globset::GlobSet {
let mut builder = globset::GlobSetBuilder::new();
for pattern in ROUTE_CONVENTION_PATTERNS {
let glob = globset::GlobBuilder::new(pattern)
.literal_separator(true)
.build()
.expect("static route-convention glob pattern must be valid");
builder.add(glob);
}
builder
.build()
.expect("static route-convention globset must build")
}
fn is_route_convention_file(absolute_path: &std::path::Path, root: &std::path::Path) -> bool {
let relative = absolute_path.strip_prefix(root).unwrap_or(absolute_path);
let normalized = relative.to_string_lossy().replace('\\', "/");
ROUTE_CONVENTION_GLOBSET.is_match(normalized.as_str())
}
static ROUTE_CONVENTION_GLOBSET: LazyLock<globset::GlobSet> =
LazyLock::new(build_route_convention_globset);
pub fn find_private_type_leaks(
graph: &ModuleGraph,
modules: &[fallow_types::extract::ModuleInfo],
config: &ResolvedConfig,
suppressions: &SuppressionContext<'_>,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Vec<PrivateTypeLeak> {
let mut leaks = Vec::new();
for module_info in modules {
if module_info.public_signature_type_references.is_empty()
|| module_info.local_type_declarations.is_empty()
{
continue;
}
let Some(module) = graph.modules.get(module_info.file_id.0 as usize) else {
continue;
};
debug_assert_eq!(
module.file_id, module_info.file_id,
"ModuleGraph::modules FileId-as-index invariant broken"
);
if is_storybook_file(&module.path) || is_route_convention_file(&module.path, &config.root) {
continue;
}
collect_module_private_type_leaks(
module,
module_info,
suppressions,
line_offsets_by_file,
&mut leaks,
);
}
leaks
}
fn collect_module_private_type_leaks(
module: &ModuleNode,
module_info: &fallow_types::extract::ModuleInfo,
suppressions: &SuppressionContext<'_>,
line_offsets_by_file: &LineOffsetsMap<'_>,
leaks: &mut Vec<PrivateTypeLeak>,
) {
let local_types: FxHashSet<&str> = module_info
.local_type_declarations
.iter()
.map(|decl| decl.name.as_str())
.collect();
let exported_names: FxHashSet<String> = module
.exports
.iter()
.map(|export| export.name.to_string())
.collect();
let mut seen: FxHashSet<(String, String)> = FxHashSet::default();
for reference in &module_info.public_signature_type_references {
if !local_types.contains(reference.type_name.as_str())
|| exported_names.contains(&reference.type_name)
{
continue;
}
if !seen.insert((reference.export_name.clone(), reference.type_name.clone())) {
continue;
}
let (line, col) = byte_offset_to_line_col(
line_offsets_by_file,
module_info.file_id,
reference.span.start,
);
if suppressions.is_suppressed(module_info.file_id, line, IssueKind::PrivateTypeLeak) {
continue;
}
leaks.push(PrivateTypeLeak {
path: module.path.clone(),
export_name: reference.export_name.clone(),
type_name: reference.type_name.clone(),
line,
col,
span_start: reference.span.start,
semantic: None,
});
}
}
type DynamicReExportSources = FxHashMap<usize, FxHashMap<String, FxHashSet<usize>>>;
fn collect_dynamic_reexport_sources(
resolved_modules: &[crate::resolve::ResolvedModule],
graph: &ModuleGraph,
re_export_sources: &mut DynamicReExportSources,
) {
use crate::extract::ExportName;
use fallow_types::extract::ImportedName;
for resolved in resolved_modules {
let wrapper_idx = resolved.file_id.0 as usize;
let Some(wrapper) = graph.modules.get(wrapper_idx) else {
continue;
};
debug_assert_eq!(
wrapper.file_id, resolved.file_id,
"ModuleGraph::modules FileId-as-index invariant broken"
);
let wrapper_exports = &wrapper.exports;
for dynamic_import in &resolved.resolved_dynamic_imports {
let Some(source_file_id) = dynamic_import.target.internal_file_id() else {
continue;
};
let ImportedName::Named(name) = &dynamic_import.info.imported_name else {
continue;
};
if !wrapper_exports
.iter()
.any(|export| matches!(&export.name, ExportName::Named(n) if n == name))
{
continue;
}
let source_idx = source_file_id.0 as usize;
if source_idx >= graph.modules.len() {
continue;
}
re_export_sources
.entry(wrapper_idx)
.or_default()
.entry(name.clone())
.or_default()
.insert(source_idx);
}
}
}
struct ExportEntry {
module_idx: usize,
path: std::path::PathBuf,
file_id: FileId,
span_start: u32,
is_type_only: bool,
}
impl ExportEntry {
const fn namespaces(&self) -> &'static [ExportNamespace] {
if self.is_type_only {
&[ExportNamespace::Type]
} else {
&[ExportNamespace::Type, ExportNamespace::Value]
}
}
fn connects_importer(&self, graph: &ModuleGraph, importer: FileId, export_name: &str) -> bool {
self.namespaces().iter().any(|&namespace| {
graph.importer_connects_export_origin(importer, self.file_id, export_name, namespace)
})
}
}
fn partition_by_common_importer(
entries: &[ExportEntry],
export_name: &str,
graph: &ModuleGraph,
) -> Vec<Vec<usize>> {
let n = entries.len();
let mut union_find = UnionFind::new(n);
let mut importer_to_entries: FxHashMap<FileId, Vec<usize>> = FxHashMap::default();
for (i, entry) in entries.iter().enumerate() {
let idx = entry.file_id.0 as usize;
if idx >= graph.reverse_deps.len() {
continue;
}
for &importer in &graph.reverse_deps[idx] {
if !entry.connects_importer(graph, importer, export_name) {
continue;
}
importer_to_entries.entry(importer).or_default().push(i);
}
}
for members in importer_to_entries.values() {
if let Some((&first, rest)) = members.split_first() {
for &other in rest {
union_find.union(first, other);
}
}
}
let mut entry_index_by_file_id: FxHashMap<FileId, usize> = FxHashMap::default();
for (i, entry) in entries.iter().enumerate() {
entry_index_by_file_id.entry(entry.file_id).or_insert(i);
}
for (i, entry) in entries.iter().enumerate() {
let idx = entry.file_id.0 as usize;
if idx >= graph.reverse_deps.len() {
continue;
}
for &importer in &graph.reverse_deps[idx] {
if !entry.connects_importer(graph, importer, export_name) {
continue;
}
if let Some(&j) = entry_index_by_file_id.get(&importer) {
union_find.union(i, j);
}
}
}
union_find.components()
}
struct UnionFind {
parent: Vec<usize>,
}
impl UnionFind {
fn new(len: usize) -> Self {
Self {
parent: (0..len).collect(),
}
}
fn find(&mut self, mut x: usize) -> usize {
while self.parent[x] != x {
self.parent[x] = self.parent[self.parent[x]];
x = self.parent[x];
}
x
}
fn union(&mut self, a: usize, b: usize) {
let root_a = self.find(a);
let root_b = self.find(b);
if root_a != root_b {
self.parent[root_a] = root_b;
}
}
fn components(mut self) -> Vec<Vec<usize>> {
let mut components: FxHashMap<usize, Vec<usize>> = FxHashMap::default();
for i in 0..self.parent.len() {
let root = self.find(i);
components.entry(root).or_default().push(i);
}
components.into_values().collect()
}
}
#[deprecated(
since = "2.76.0",
note = "fallow_core is internal; use fallow_api::run_dead_code for typed output; serialize with fallow_api::serialize_dead_code_programmatic_json for JSON output. See docs/fallow-core-migration.md."
)]
pub fn find_duplicate_exports(
graph: &ModuleGraph,
config: &ResolvedConfig,
suppressions: &SuppressionContext<'_>,
line_offsets_by_file: &LineOffsetsMap<'_>,
resolved_modules: &[crate::resolve::ResolvedModule],
) -> Vec<DuplicateExport> {
find_duplicate_exports_with_plugins(
graph,
config,
suppressions,
line_offsets_by_file,
None,
resolved_modules,
)
}
pub(super) fn find_duplicate_exports_with_plugins(
graph: &ModuleGraph,
config: &ResolvedConfig,
suppressions: &SuppressionContext<'_>,
line_offsets_by_file: &LineOffsetsMap<'_>,
plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
resolved_modules: &[crate::resolve::ResolvedModule],
) -> Vec<DuplicateExport> {
let dynamic_re_export_sources = build_dynamic_re_export_source_map(graph, resolved_modules);
let export_locations =
collect_duplicate_export_locations(graph, config, suppressions, plugin_result);
let collapse_platform_families = is_react_native_active(plugin_result);
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)| {
evaluate_duplicate_export_group(
name,
locations,
&dynamic_re_export_sources,
graph,
line_offsets_by_file,
collapse_platform_families,
)
})
.collect()
}
fn build_dynamic_re_export_source_map(
graph: &ModuleGraph,
resolved_modules: &[crate::resolve::ResolvedModule],
) -> DynamicReExportSources {
let mut re_export_sources = DynamicReExportSources::default();
collect_dynamic_reexport_sources(resolved_modules, graph, &mut re_export_sources);
re_export_sources
}
fn collect_duplicate_export_locations(
graph: &ModuleGraph,
config: &ResolvedConfig,
suppressions: &SuppressionContext<'_>,
plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
) -> FxHashMap<String, Vec<ExportEntry>> {
let ignore_matchers = config.compiled_ignore_exports.as_slice();
let tanstack_duplicate_matchers = compile_tanstack_duplicate_export_matchers(plugin_result);
let has_tanstack_router = is_tanstack_router_active(plugin_result);
let mut export_locations: FxHashMap<String, Vec<ExportEntry>> = FxHashMap::default();
for (idx, module) in graph.modules.iter().enumerate() {
collect_module_duplicate_export_locations(
&mut export_locations,
DuplicateExportModuleInput {
graph,
config,
suppressions,
ignore_matchers,
tanstack_duplicate_matchers: &tanstack_duplicate_matchers,
has_tanstack_router,
module_idx: idx,
module,
},
);
}
export_locations
}
#[derive(Clone, Copy)]
struct DuplicateExportModuleInput<'a> {
graph: &'a ModuleGraph,
config: &'a ResolvedConfig,
suppressions: &'a SuppressionContext<'a>,
ignore_matchers: &'a [CompiledIgnoreExportRule],
tanstack_duplicate_matchers: &'a PluginMatchers<'a>,
has_tanstack_router: bool,
module_idx: usize,
module: &'a ModuleNode,
}
fn collect_module_duplicate_export_locations(
export_locations: &mut FxHashMap<String, Vec<ExportEntry>>,
input: DuplicateExportModuleInput<'_>,
) {
if !input.module.is_reachable()
|| input.module.is_entry_point()
|| is_css_module_path(&input.module.path)
{
return;
}
if input
.suppressions
.is_file_suppressed(input.module.file_id, IssueKind::DuplicateExport)
{
return;
}
let matching_ignore = ignore_matchers_for_module(
&input.module.path,
&input.config.root,
input.ignore_matchers,
);
let (_, matching_tanstack_contracts) = matchers_for_module(
&input.module.path,
&input.config.root,
&[],
input.tanstack_duplicate_matchers,
);
for export in &input.module.exports {
let Some((name, entry)) = duplicate_export_entry(
export,
&input,
&matching_ignore,
&matching_tanstack_contracts,
) else {
continue;
};
export_locations.entry(name).or_default().push(entry);
}
}
fn duplicate_export_entry(
export: &ExportSymbol,
input: &DuplicateExportModuleInput<'_>,
matching_ignore: &[&[String]],
matching_tanstack_contracts: &[&[&str]],
) -> Option<(String, ExportEntry)> {
if matches!(export.name, crate::extract::ExportName::Default)
|| matches!(&export.name, crate::extract::ExportName::Named(name) if name == "default")
{
return None;
}
if export.span.start == 0 && export.span.end == 0 {
return None;
}
let name = export.name.to_string();
if is_export_ignored(&name, matching_ignore, matching_tanstack_contracts) {
return None;
}
if input.has_tanstack_router
&& is_route_referenced_by_tanstack_generated_tree(
&name,
export,
input.module,
input.graph,
&input.config.root,
)
{
return None;
}
Some((
name,
ExportEntry {
module_idx: input.module_idx,
path: input.module.path.clone(),
file_id: input.module.file_id,
span_start: export.span.start,
is_type_only: export.is_type_only,
},
))
}
fn is_css_module_path(path: &std::path::Path) -> bool {
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
return false;
};
let Some((stem, extension)) = file_name.rsplit_once('.') else {
return false;
};
stem.ends_with(".module") && matches!(extension, "css" | "scss" | "sass" | "less")
}
fn evaluate_duplicate_export_group(
name: String,
locations: Vec<ExportEntry>,
dynamic_re_export_sources: &DynamicReExportSources,
graph: &ModuleGraph,
line_offsets_by_file: &LineOffsetsMap<'_>,
collapse_platform_families: bool,
) -> Option<DuplicateExport> {
if locations.len() <= 1 {
return None;
}
let module_indices: FxHashSet<usize> = locations.iter().map(|e| e.module_idx).collect();
let independent_entries: Vec<ExportEntry> = locations
.into_iter()
.filter(|entry| {
!is_re_export_chain_member(
entry,
&name,
&module_indices,
dynamic_re_export_sources,
graph,
)
})
.collect();
let independent_entries = if collapse_platform_families {
collapse_platform_family_entries(independent_entries)
} else {
independent_entries
};
if independent_entries.len() <= 1 {
return None;
}
let components = partition_by_common_importer(&independent_entries, &name, graph);
let mut surviving_locations: Vec<DuplicateLocation> = Vec::new();
for component_indices in components {
surviving_locations.extend(component_duplicate_locations(
&component_indices,
&independent_entries,
line_offsets_by_file,
));
}
if surviving_locations.len() <= 1 {
return None;
}
Some(DuplicateExport {
export_name: name,
locations: surviving_locations,
})
}
fn collapse_platform_family_entries(entries: Vec<ExportEntry>) -> Vec<ExportEntry> {
let keep = platform_family_keep_mask(&entries);
entries
.into_iter()
.zip(keep)
.filter_map(|(entry, keep)| keep.then_some(entry))
.collect()
}
fn platform_family_keep_mask(entries: &[ExportEntry]) -> Vec<bool> {
let mut families: FxHashMap<(&std::path::Path, &str), Vec<(usize, bool)>> =
FxHashMap::default();
for (i, entry) in entries.iter().enumerate() {
let Some(key) = platform_family_key(&entry.path) else {
continue;
};
families
.entry((key.parent, key.base))
.or_default()
.push((i, key.is_platform_variant));
}
let mut keep = vec![true; entries.len()];
for members in families.into_values() {
let Some(representative) = platform_family_representative(entries, &members) else {
continue;
};
for &(i, _) in &members {
if entries[i].file_id != representative {
keep[i] = false;
}
}
}
keep
}
fn platform_family_representative(
entries: &[ExportEntry],
members: &[(usize, bool)],
) -> Option<FileId> {
let distinct_files: FxHashSet<FileId> =
members.iter().map(|&(i, _)| entries[i].file_id).collect();
if distinct_files.len() < 2 || !members.iter().any(|&(_, is_variant)| is_variant) {
return None;
}
let base_members = members
.iter()
.filter(|&&(_, is_variant)| !is_variant)
.map(|&(i, _)| &entries[i]);
let representative = match base_members.min_by_key(|entry| &entry.path) {
Some(base) => base,
None => members
.iter()
.map(|&(i, _)| &entries[i])
.min_by_key(|entry| &entry.path)?,
};
Some(representative.file_id)
}
fn is_re_export_chain_member(
entry: &ExportEntry,
export_name: &str,
group_modules: &FxHashSet<usize>,
dynamic_re_export_sources: &DynamicReExportSources,
graph: &ModuleGraph,
) -> bool {
let is_static_re_export = entry.namespaces().iter().any(|&namespace| {
matches!(
graph.resolve_export(entry.file_id, export_name, namespace),
EffectiveExportResolution::Unique(binding)
if binding.origin_file() != entry.file_id
&& group_modules.contains(&(binding.origin_file().0 as usize))
)
});
if is_static_re_export {
return true;
}
dynamic_re_export_sources
.get(&entry.module_idx)
.and_then(|exports| exports.get(export_name))
.is_some_and(|sources| sources.iter().any(|source| group_modules.contains(source)))
}
fn component_duplicate_locations(
component_indices: &[usize],
independent_entries: &[ExportEntry],
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Vec<DuplicateLocation> {
if component_indices.len() <= 1 {
return Vec::new();
}
let value_count = component_indices
.iter()
.filter(|&&i| !independent_entries[i].is_type_only)
.count();
let type_count = component_indices
.iter()
.filter(|&&i| independent_entries[i].is_type_only)
.count();
if value_count == 1 && type_count == 1 {
return Vec::new();
}
component_indices
.iter()
.map(|&i| {
let e = &independent_entries[i];
let (line, col) =
byte_offset_to_line_col(line_offsets_by_file, e.file_id, e.span_start);
DuplicateLocation {
path: e.path.clone(),
line,
col,
}
})
.collect()
}
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_count, reference_locations) = export_reference_locations(
export,
&file_paths,
line_offsets_by_file,
&mut source_cache,
);
usages.push(ExportUsage {
path: module.path.clone(),
export_name: export.name.to_string(),
line,
col,
reference_count,
reference_locations,
});
}
}
usages
}
fn export_reference_locations(
export: &ExportSymbol,
file_paths: &FxHashMap<FileId, &std::path::Path>,
line_offsets_by_file: &LineOffsetsMap<'_>,
source_cache: &mut FxHashMap<FileId, (String, Vec<u32>)>,
) -> (usize, Vec<ReferenceLocation>) {
let mut reference_count = 0;
let locations = export
.physical_references()
.filter_map(|r| {
reference_count += 1;
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();
(reference_count, locations)
}
#[cfg(test)]
#[expect(
deprecated,
reason = "Core-internal policy keeps direct detector unit tests while the public warning targets external callers"
)]
mod tests {
use super::*;
use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
use crate::extract::{
ExportInfo, ExportName, ImportInfo, ImportedName, ReExportInfo, VisibilityTag,
};
use crate::graph::{
ExportNamespace, ExportSymbol, ModuleGraph, ReExportEdge, ReferenceKind, SymbolReference,
};
use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule, ResolvedReExport};
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![].into(),
re_exports: vec![],
resolved_imports: vec![],
resolved_dynamic_imports: vec![],
resolved_dynamic_patterns: vec![],
member_accesses: vec![].into(),
semantic_facts: std::sync::Arc::default(),
whole_object_uses: std::sync::Arc::default(),
has_cjs_exports: false,
has_angular_component_template_url: false,
unused_import_bindings: FxHashSet::default(),
type_referenced_import_bindings: vec![],
value_referenced_import_bindings: vec![],
namespace_object_aliases: vec![],
exported_factory_returns: std::sync::Arc::default(),
exported_factory_return_object_shapes: std::sync::Arc::default(),
type_member_types: std::sync::Arc::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,
None,
)
}
fn make_export(name: &str, span_start: u32, span_end: u32) -> ExportSymbol {
ExportSymbol {
name: ExportName::Named(name.to_string()),
is_type_only: false,
is_side_effect_used: false,
visibility: VisibilityTag::None,
expected_unused_reason: None,
span: Span::new(span_start, span_end),
references: vec![],
reference_paths: Vec::new(),
members: vec![],
}
}
#[expect(
clippy::cast_possible_truncation,
reason = "test file counts are trivially small"
)]
fn build_graph_with_referenced_export(
file_specs: &[(&str, bool)],
target: usize,
name: &str,
span_start: u32,
span_end: u32,
from: u32,
) -> ModuleGraph {
let files: Vec<DiscoveredFile> = file_specs
.iter()
.enumerate()
.map(|(index, (path, _))| DiscoveredFile {
id: FileId(index 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 mut resolved_modules: Vec<ResolvedModule> = files
.iter()
.map(|file| ResolvedModule {
file_id: file.id,
path: file.path.clone(),
..Default::default()
})
.collect();
resolved_modules[target].exports = vec![ExportInfo {
name: ExportName::Named(name.to_string()),
local_name: None,
is_type_only: false,
is_side_effect_used: false,
visibility: VisibilityTag::None,
expected_unused_reason: None,
span: Span::new(span_start, span_end),
members: vec![],
super_class: None,
}]
.into();
resolved_modules[from as usize]
.resolved_imports
.push(ResolvedImport {
info: ImportInfo {
source: format!("./fixture-{target}"),
imported_name: ImportedName::Named(name.to_string()),
local_name: name.to_string(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: Span::new(0, 10),
source_span: Span::default(),
},
target: ResolveResult::InternalModule(FileId(target as u32)),
});
ModuleGraph::build(&resolved_modules, &entry_points, &files)
}
#[expect(
clippy::cast_possible_truncation,
reason = "test file counts are trivially small"
)]
fn build_duplicate_graph(
file_specs: &[(&str, bool)],
exports: &[(usize, &str, bool)],
imports: &[(usize, usize, &str, bool)],
) -> ModuleGraph {
let files: Vec<DiscoveredFile> = file_specs
.iter()
.enumerate()
.map(|(index, (path, _))| DiscoveredFile {
id: FileId(index 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 mut resolved_modules: Vec<ResolvedModule> = files
.iter()
.map(|file| ResolvedModule {
file_id: file.id,
path: file.path.clone(),
..Default::default()
})
.collect();
let mut exports_by_module = vec![Vec::new(); resolved_modules.len()];
for &(module, name, is_type_only) in exports {
exports_by_module[module].push(ExportInfo {
name: ExportName::Named(name.to_string()),
local_name: Some(name.to_string()),
is_type_only,
visibility: VisibilityTag::None,
expected_unused_reason: None,
span: Span::new(10, 20),
members: vec![],
is_side_effect_used: false,
super_class: None,
});
}
for (module, module_exports) in resolved_modules.iter_mut().zip(exports_by_module) {
module.exports = module_exports.into();
}
for &(importer, source, name, is_type_only) in imports {
resolved_modules[importer]
.resolved_imports
.push(ResolvedImport {
info: ImportInfo {
source: format!("./{source}"),
imported_name: ImportedName::Named(name.to_string()),
local_name: format!("{name}{source}"),
is_type_only,
is_type_only_star: false,
from_style: false,
span: Span::new(1, 4),
source_span: Span::default(),
},
target: ResolveResult::InternalModule(FileId(source as u32)),
});
}
ModuleGraph::build(&resolved_modules, &entry_points, &files)
}
#[test]
fn duplicate_exports_empty_graph() {
let graph = build_graph(&[]);
let suppressions = SuppressionContext::empty();
let config = test_config();
let result =
find_duplicate_exports(&graph, &config, &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 config = test_config();
let result =
find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
assert!(result.is_empty());
}
#[test]
fn duplicate_exports_detects_same_name_in_two_modules() {
let graph = build_duplicate_graph(
&[
("/src/entry.ts", true),
("/src/a.ts", false),
("/src/b.ts", false),
],
&[(1, "helper", false), (2, "helper", false)],
&[(0, 1, "helper", false), (0, 2, "helper", false)],
);
let suppressions = SuppressionContext::empty();
let config = test_config();
let result =
find_duplicate_exports(&graph, &config, &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,
is_side_effect_used: false,
visibility: VisibilityTag::None,
expected_unused_reason: None,
span: Span::new(10, 20),
references: vec![],
reference_paths: Vec::new(),
members: vec![],
}];
graph.modules[2].set_reachable(true);
graph.modules[2].exports = vec![ExportSymbol {
name: ExportName::Default,
is_type_only: false,
is_side_effect_used: false,
visibility: VisibilityTag::None,
expected_unused_reason: None,
span: Span::new(10, 20),
references: vec![],
reference_paths: Vec::new(),
members: vec![],
}];
let suppressions = SuppressionContext::empty();
let config = test_config();
let result =
find_duplicate_exports(&graph, &config, &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 config = test_config();
let result =
find_duplicate_exports(&graph, &config, &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 config = test_config();
let result =
find_duplicate_exports(&graph, &config, &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 config = test_config();
let result =
find_duplicate_exports(&graph, &config, &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 config = test_config();
let result =
find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
assert!(result.is_empty());
}
#[test]
fn duplicate_exports_follow_type_only_star_routes_for_value_backed_exports() {
let paths = ["/src/entry.ts", "/src/barrel.ts", "/src/a.ts", "/src/b.ts"];
let files: Vec<DiscoveredFile> = paths
.iter()
.enumerate()
.map(|(index, path)| DiscoveredFile {
id: FileId(index as u32),
path: PathBuf::from(path),
size_bytes: 0,
})
.collect();
let entry_points = vec![EntryPoint {
path: files[0].path.clone(),
source: EntryPointSource::ManualEntry,
}];
let class_export = |file_id: u32| ResolvedModule {
file_id: FileId(file_id),
path: files[file_id as usize].path.clone(),
exports: vec![ExportInfo {
name: ExportName::Named("Foo".to_string()),
local_name: Some("Foo".to_string()),
is_type_only: false,
is_side_effect_used: false,
visibility: VisibilityTag::None,
expected_unused_reason: None,
span: Span::new(10, 20),
members: vec![],
super_class: None,
}]
.into(),
..Default::default()
};
let type_star = |source: &str, target: u32| ResolvedReExport {
info: ReExportInfo {
source: source.to_string(),
imported_name: "*".to_string(),
exported_name: "*".to_string(),
is_type_only: true,
span: Span::new(10, 20),
statement_span: Span::new(10, 30),
source_span: Span::new(24, 29),
},
target: ResolveResult::InternalModule(FileId(target)),
};
let resolved_modules = vec![
ResolvedModule {
file_id: FileId(0),
path: files[0].path.clone(),
resolved_imports: vec![ResolvedImport {
info: ImportInfo {
source: "./barrel".to_string(),
imported_name: ImportedName::Named("Foo".to_string()),
local_name: "Foo".to_string(),
is_type_only: true,
is_type_only_star: false,
from_style: false,
span: Span::new(0, 10),
source_span: Span::new(0, 1),
},
target: ResolveResult::InternalModule(FileId(1)),
}],
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: files[1].path.clone(),
re_exports: vec![type_star("./a", 2), type_star("./b", 3)],
..Default::default()
},
class_export(2),
class_export(3),
];
let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
let result = find_duplicate_exports(
&graph,
&test_config(),
&SuppressionContext::empty(),
&FxHashMap::default(),
&resolved_modules,
);
assert_eq!(
result.len(),
1,
"ambiguous type-only stars must retain both class exports"
);
assert_eq!(result[0].export_name, "Foo");
assert_eq!(result[0].locations.len(), 2);
}
#[test]
fn duplicate_exports_preserves_local_export_with_unrelated_re_export() {
let files = vec![
DiscoveredFile {
id: FileId(0),
path: PathBuf::from("/src/entry.ts"),
size_bytes: 0,
},
DiscoveredFile {
id: FileId(1),
path: PathBuf::from("/src/source.ts"),
size_bytes: 0,
},
DiscoveredFile {
id: FileId(2),
path: PathBuf::from("/src/barrel.ts"),
size_bytes: 0,
},
];
let entry_points = vec![EntryPoint {
path: files[0].path.clone(),
source: EntryPointSource::ManualEntry,
}];
let imported_foo = |target: FileId| ResolvedImport {
info: ImportInfo {
source: format!("./{}", target.0),
imported_name: ImportedName::Named("Foo".to_string()),
local_name: format!("Foo{}", target.0),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: Span::new(1, 4),
source_span: Span::default(),
},
target: ResolveResult::InternalModule(target),
};
let export = |name: &str, span: Span| ExportInfo {
name: ExportName::Named(name.to_string()),
local_name: Some(name.to_string()),
is_type_only: false,
visibility: VisibilityTag::None,
expected_unused_reason: None,
span,
members: vec![],
is_side_effect_used: false,
super_class: None,
};
let resolved_modules = vec![
ResolvedModule {
file_id: FileId(0),
path: files[0].path.clone(),
resolved_imports: vec![imported_foo(FileId(1)), imported_foo(FileId(2))],
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: files[1].path.clone(),
exports: vec![
export("Foo", Span::new(10, 13)),
export("Bar", Span::new(20, 23)),
]
.into(),
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: files[2].path.clone(),
exports: vec![export("Foo", Span::new(30, 33))].into(),
re_exports: vec![ResolvedReExport {
info: ReExportInfo {
source: "./source".to_string(),
imported_name: "Bar".to_string(),
exported_name: "Bar".to_string(),
is_type_only: false,
span: Span::new(40, 43),
statement_span: Span::new(35, 44),
source_span: Span::new(35, 39),
},
target: ResolveResult::InternalModule(FileId(1)),
}],
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
let result = find_duplicate_exports(
&graph,
&test_config(),
&SuppressionContext::empty(),
&FxHashMap::default(),
&resolved_modules,
);
assert_eq!(result.len(), 1);
assert_eq!(result[0].export_name, "Foo");
assert_eq!(result[0].locations.len(), 2);
}
#[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::issue(0, 1, 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 config = test_config();
let result =
find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
assert!(result.is_empty());
}
#[test]
fn duplicate_exports_three_modules_same_name() {
let graph = build_duplicate_graph(
&[
("/src/entry.ts", true),
("/src/a.ts", false),
("/src/b.ts", false),
("/src/c.ts", false),
],
&[
(1, "sharedFn", false),
(2, "sharedFn", false),
(3, "sharedFn", false),
],
&[
(0, 1, "sharedFn", false),
(0, 2, "sharedFn", false),
(0, 3, "sharedFn", false),
],
);
let suppressions = SuppressionContext::empty();
let config = test_config();
let result =
find_duplicate_exports(&graph, &config, &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 config = test_config();
let result =
find_duplicate_exports(&graph, &config, &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 graph = build_duplicate_graph(
&[
("/src/entry.ts", true),
("/src/a.ts", false),
("/src/b.ts", false),
],
&[(1, "helper", false), (2, "helper", false)],
&[(0, 1, "helper", false), (1, 2, "helper", false)],
);
let suppressions = SuppressionContext::empty();
let config = test_config();
let result =
find_duplicate_exports(&graph, &config, &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 config = test_config();
let result =
find_duplicate_exports(&graph, &config, &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 config = test_config();
let result =
find_duplicate_exports(&graph, &config, &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 config = test_config();
let result =
find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
assert!(
result.is_empty(),
"cross-file value+type should not be flagged"
);
}
#[test]
fn duplicate_exports_same_namespace_still_flagged() {
let graph = build_duplicate_graph(
&[
("/src/entry.ts", true),
("/src/a.ts", false),
("/src/b.ts", false),
],
&[(1, "helper", false), (2, "helper", false)],
&[(0, 1, "helper", false), (0, 2, "helper", false)],
);
let suppressions = SuppressionContext::empty();
let config = test_config();
let result =
find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
assert_eq!(
result.len(),
1,
"same-namespace duplicates should still be flagged"
);
}
#[test]
fn duplicate_exports_cross_package_backend_does_not_defeat_value_type_suppression() {
let mut graph = build_graph(&[
("/src/backend/entry.ts", true),
("/src/frontend/entry.ts", true),
("/src/backend/label.ts", false),
("/src/frontend/label.ts", false),
("/src/frontend/label.types.ts", false),
("/src/frontend/consumer.ts", false),
]);
graph.modules[2].set_reachable(true);
graph.modules[3].set_reachable(true);
graph.modules[4].set_reachable(true);
graph.modules[5].set_reachable(true);
graph.modules[2].exports = vec![make_export("Label", 10, 20)];
graph.modules[3].exports = vec![make_export("Label", 10, 20)];
graph.modules[4].exports = vec![make_type_export("Label", 10, 20)];
graph.reverse_deps[2] = vec![FileId(0)];
graph.reverse_deps[3] = vec![FileId(5)];
graph.reverse_deps[4] = vec![FileId(5)];
graph.reverse_deps[5] = vec![FileId(1)];
let suppressions = SuppressionContext::empty();
let config = test_config();
let result =
find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
assert!(
result.is_empty(),
"backend `class Label` shares no importer with the frontend value+type pair; \
the frontend pair must self-suppress, so no duplicate should be reported, \
but got: {result:?}",
);
}
#[test]
fn duplicate_exports_genuine_value_duplicate_still_flagged_despite_unrelated_backend() {
let graph = build_duplicate_graph(
&[
("/src/entry.ts", true),
("/src/backend/label.ts", false),
("/src/frontend/a.ts", false),
("/src/frontend/b.ts", false),
("/src/frontend/consumer.ts", false),
],
&[
(1, "Label", false),
(2, "Label", false),
(3, "Label", false),
(4, "consumer", false),
],
&[
(0, 1, "Label", false),
(0, 4, "consumer", false),
(4, 2, "Label", false),
(4, 3, "Label", false),
],
);
let suppressions = SuppressionContext::empty();
let config = test_config();
let result =
find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
assert_eq!(
result.len(),
1,
"frontend-a and frontend-b share a common importer and both export a value \
`Label`, so a genuine duplicate must be reported even though an unrelated \
backend module also exports `Label`"
);
assert_eq!(result[0].export_name, "Label");
assert_eq!(
result[0].locations.len(),
2,
"only the two connected frontend locations should be in the finding"
);
}
fn make_resolved_module(file_id: u32, path: &str) -> crate::resolve::ResolvedModule {
crate::resolve::ResolvedModule {
file_id: FileId(file_id),
path: std::path::PathBuf::from(path),
exports: vec![].into(),
re_exports: vec![],
resolved_imports: vec![],
resolved_dynamic_imports: vec![],
resolved_dynamic_patterns: vec![],
member_accesses: vec![].into(),
semantic_facts: std::sync::Arc::default(),
whole_object_uses: std::sync::Arc::default(),
has_cjs_exports: false,
has_angular_component_template_url: false,
unused_import_bindings: FxHashSet::default(),
type_referenced_import_bindings: vec![],
value_referenced_import_bindings: vec![],
namespace_object_aliases: vec![],
exported_factory_returns: std::sync::Arc::default(),
exported_factory_return_object_shapes: std::sync::Arc::default(),
type_member_types: std::sync::Arc::default(),
}
}
fn dynamic_import_to(
target_id: u32,
imported_name: crate::extract::ImportedName,
) -> crate::resolve::ResolvedImport {
crate::resolve::ResolvedImport {
info: fallow_types::extract::ImportInfo {
source: "./target".to_string(),
imported_name,
local_name: "_local".to_string(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: oxc_span::Span::new(0, 1),
source_span: oxc_span::Span::default(),
},
target: crate::resolve::ResolveResult::InternalModule(FileId(target_id)),
}
}
#[test]
fn dynamic_import_then_member_not_flagged_as_duplicate() {
use crate::extract::ImportedName;
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/Foo.tsx", false),
("/src/Foo-lazy.tsx", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_export("Foo", 10, 30)];
graph.modules[2].set_reachable(true);
graph.modules[2].exports = vec![make_export("Foo", 10, 30)];
graph.reverse_deps[1] = vec![FileId(0)];
graph.reverse_deps[2] = vec![FileId(0)];
let mut foo_lazy = make_resolved_module(2, "/src/Foo-lazy.tsx");
foo_lazy.resolved_dynamic_imports =
vec![dynamic_import_to(1, ImportedName::Named("Foo".to_string()))];
let resolved_modules = vec![make_resolved_module(1, "/src/Foo.tsx"), foo_lazy];
let suppressions = SuppressionContext::empty();
let config = test_config();
let result = find_duplicate_exports(
&graph,
&config,
&suppressions,
&FxHashMap::default(),
&resolved_modules,
);
assert!(
result.is_empty(),
"dynamic(import().then(m=>m.Foo)) wrapper must not be flagged as duplicate-export"
);
}
#[test]
fn dynamic_import_without_then_default_not_flagged_as_duplicate() {
use crate::extract::{ExportName, ImportedName, VisibilityTag};
use crate::graph::ExportSymbol;
let make_default_export = || ExportSymbol {
name: ExportName::Default,
is_type_only: false,
is_side_effect_used: false,
visibility: VisibilityTag::None,
expected_unused_reason: None,
span: oxc_span::Span::new(0, 10),
references: vec![],
reference_paths: Vec::new(),
members: vec![],
};
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/Foo.tsx", false),
("/src/Foo-lazy.tsx", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports = vec![make_default_export()];
graph.modules[2].set_reachable(true);
graph.modules[2].exports = vec![make_default_export()];
graph.reverse_deps[1] = vec![FileId(0)];
graph.reverse_deps[2] = vec![FileId(0)];
let mut foo_lazy = make_resolved_module(2, "/src/Foo-lazy.tsx");
foo_lazy.resolved_dynamic_imports = vec![dynamic_import_to(1, ImportedName::Default)];
let resolved_modules = vec![make_resolved_module(1, "/src/Foo.tsx"), foo_lazy];
let suppressions = SuppressionContext::empty();
let config = test_config();
let result = find_duplicate_exports(
&graph,
&config,
&suppressions,
&FxHashMap::default(),
&resolved_modules,
);
assert!(
result.is_empty(),
"dynamic(import('./Foo')) wrapper with matching default export must not be flagged"
);
}
#[test]
fn dynamic_import_named_without_matching_export_still_flagged() {
use crate::extract::ImportedName;
let graph = build_duplicate_graph(
&[
("/src/entry.ts", true),
("/src/Foo.tsx", false),
("/src/other-foo.tsx", false),
("/src/wrapper.tsx", false),
],
&[(1, "Foo", false), (2, "Foo", false), (3, "Bar", false)],
&[(0, 1, "Foo", false), (0, 2, "Foo", false)],
);
let mut wrapper = make_resolved_module(3, "/src/wrapper.tsx");
wrapper.resolved_dynamic_imports =
vec![dynamic_import_to(1, ImportedName::Named("Foo".to_string()))];
let resolved_modules = vec![
make_resolved_module(1, "/src/Foo.tsx"),
make_resolved_module(2, "/src/other-foo.tsx"),
wrapper,
];
let suppressions = SuppressionContext::empty();
let config = test_config();
let result = find_duplicate_exports(
&graph,
&config,
&suppressions,
&FxHashMap::default(),
&resolved_modules,
);
assert_eq!(
result.len(),
1,
"Foo duplication between unrelated modules must be flagged"
);
assert_eq!(result[0].export_name, "Foo");
}
#[test]
fn dynamic_import_default_without_default_export_still_flagged() {
use crate::extract::ImportedName;
let graph = build_duplicate_graph(
&[
("/src/entry.ts", true),
("/src/source.ts", false),
("/src/wrapper.ts", false),
],
&[(1, "helper", false), (2, "helper", false)],
&[(0, 1, "helper", false), (0, 2, "helper", false)],
);
let mut wrapper = make_resolved_module(2, "/src/wrapper.ts");
wrapper.resolved_dynamic_imports = vec![dynamic_import_to(1, ImportedName::Default)];
let resolved_modules = vec![make_resolved_module(1, "/src/source.ts"), wrapper];
let suppressions = SuppressionContext::empty();
let config = test_config();
let result = find_duplicate_exports(
&graph,
&config,
&suppressions,
&FxHashMap::default(),
&resolved_modules,
);
assert_eq!(
result.len(),
1,
"Default dynamic import without matching Default export must not suppress duplicate-export"
);
assert_eq!(result[0].export_name, "helper");
}
#[test]
fn duplicate_without_dynamic_link_still_flagged() {
let graph = build_duplicate_graph(
&[
("/src/entry.ts", true),
("/src/a.ts", false),
("/src/b.ts", false),
],
&[(1, "helper", false), (2, "helper", false)],
&[(0, 1, "helper", false), (0, 2, "helper", false)],
);
let resolved_modules = vec![
make_resolved_module(1, "/src/a.ts"),
make_resolved_module(2, "/src/b.ts"),
];
let suppressions = SuppressionContext::empty();
let config = test_config();
let result = find_duplicate_exports(
&graph,
&config,
&suppressions,
&FxHashMap::default(),
&resolved_modules,
);
assert_eq!(result.len(), 1);
assert_eq!(result[0].export_name, "helper");
}
#[test]
fn dynamic_import_named_mismatched_with_wrapper_export_still_flagged() {
use crate::extract::ImportedName;
let graph = build_duplicate_graph(
&[
("/src/entry.ts", true),
("/src/source.ts", false),
("/src/wrapper.ts", false),
],
&[(1, "Foo", false), (2, "Foo", false)],
&[(0, 1, "Foo", false), (0, 2, "Foo", false)],
);
let mut wrapper = make_resolved_module(2, "/src/wrapper.ts");
wrapper.resolved_dynamic_imports =
vec![dynamic_import_to(1, ImportedName::Named("Bar".to_string()))];
let resolved_modules = vec![make_resolved_module(1, "/src/source.ts"), wrapper];
let suppressions = SuppressionContext::empty();
let config = test_config();
let result = find_duplicate_exports(
&graph,
&config,
&suppressions,
&FxHashMap::default(),
&resolved_modules,
);
assert_eq!(
result.len(),
1,
"Named dynamic import whose name does not match the wrapper's own export must not suppress the duplicate"
);
assert_eq!(result[0].export_name, "Foo");
}
#[test]
fn duplicate_exports_skipped_when_ignore_exports_matches_with_wildcard() {
let mut graph = build_graph(&[
("/src/entry.ts", true),
("/src/ui/dialog/index.ts", false),
("/src/ui/card/index.ts", false),
]);
graph.modules[1].set_reachable(true);
graph.modules[1].exports =
vec![make_export("Root", 10, 30), make_export("Content", 40, 60)];
graph.modules[2].set_reachable(true);
graph.modules[2].exports =
vec![make_export("Root", 10, 30), make_export("Content", 40, 60)];
graph.reverse_deps[1] = vec![FileId(0)];
graph.reverse_deps[2] = vec![FileId(0)];
let suppressions = SuppressionContext::empty();
let config = test_config_with_ignore_exports(vec![fallow_config::IgnoreExportRule {
file: "**/ui/**".to_owned(),
exports: vec!["*".to_owned()],
}]);
let result =
find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
assert!(
result.is_empty(),
"wildcard ignoreExports on a glob matching both barrels must clear all duplicate-export groups, got: {result:?}"
);
}
#[test]
fn duplicate_exports_skipped_when_ignore_exports_lists_specific_names() {
let graph = build_duplicate_graph(
&[
("/src/entry.ts", true),
("/src/ui/dialog/index.ts", false),
("/src/ui/card/index.ts", false),
],
&[
(1, "Root", false),
(1, "Helper", false),
(2, "Root", false),
(2, "Helper", false),
],
&[
(0, 1, "Root", false),
(0, 1, "Helper", false),
(0, 2, "Root", false),
(0, 2, "Helper", false),
],
);
let suppressions = SuppressionContext::empty();
let config = test_config_with_ignore_exports(vec![fallow_config::IgnoreExportRule {
file: "**/ui/**".to_owned(),
exports: vec!["Root".to_owned()],
}]);
let result =
find_duplicate_exports(&graph, &config, &suppressions, &FxHashMap::default(), &[]);
let names: Vec<_> = result.iter().map(|d| d.export_name.as_str()).collect();
assert!(
!names.contains(&"Root"),
"Root listed in ignoreExports must not surface, got: {names:?}"
);
assert!(
names.contains(&"Helper"),
"Helper not in the ignore list must still surface, got: {names:?}"
);
}
fn test_config_with_ignore_exports(
rules: Vec<fallow_config::IgnoreExportRule>,
) -> ResolvedConfig {
fallow_config::FallowConfig {
ignore_exports: rules,
ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
..Default::default()
}
.resolve(
PathBuf::from("/tmp/test"),
fallow_config::OutputFormat::Human,
1,
true,
true,
None,
)
}
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![],
framework_class_member_contracts: vec![],
scss_include_paths: vec![],
entry_point_roles: FxHashMap::default(),
referenced_dependencies: vec![],
package_referenced_dependencies: vec![],
discovered_always_used: vec![],
setup_files: vec![],
tooling_dependencies: vec![],
script_used_packages: FxHashSet::default(),
virtual_module_prefixes: vec![],
virtual_package_suffixes: vec![],
generated_import_patterns: vec![],
generated_type_import_prefixes: vec![],
path_aliases: vec![],
auto_imports: vec![],
active_plugins: vec![],
fixture_patterns: vec![],
static_dir_mappings: vec![],
provided_dependencies: 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,
is_side_effect_used: false,
visibility: VisibilityTag::None,
expected_unused_reason: None,
span: Span::new(span_start, span_end),
references: vec![],
reference_paths: Vec::new(),
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 graph = build_graph_with_referenced_export(
&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/utils.ts", false),
],
1,
"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,
is_side_effect_used: false,
visibility: VisibilityTag::Public,
expected_unused_reason: None,
span: Span::new(10, 20),
references: vec![],
reference_paths: Vec::new(),
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]
#[should_panic(expected = "validated at config load time")]
fn unused_exports_panics_on_unvalidated_invalid_ignore_glob() {
let _ = test_config_with_ignore_exports(vec![fallow_config::IgnoreExportRule {
file: "[invalid".to_string(),
exports: vec!["*".to_string()],
}]);
}
#[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 graph = build_graph_with_referenced_export(
&[("/src/utils.ts", true), ("/src/app.ts", false)],
0,
"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_collapses_namespaces_at_one_physical_site() {
let mut graph = build_graph(&[("/src/utils.ts", true), ("/src/app.ts", false)]);
let mut export = make_export("helper", 10, 20);
export.references = [ExportNamespace::Type, ExportNamespace::Value]
.into_iter()
.map(|namespace| SymbolReference {
from_file: FileId(1),
kind: ReferenceKind::NamedImport,
namespace,
import_span: Span::new(0, 10),
})
.collect();
graph.modules[0].exports = vec![export];
let result = collect_export_usages(&graph, &FxHashMap::default());
assert_eq!(result[0].reference_count, 1);
assert_eq!(result[0].reference_locations.len(), 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_with_referenced_export(
&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/helpers.ts", false),
("/tmp/test/src/setup.ts", false),
],
1,
"usedByUnreachable",
10,
30,
2,
);
graph.modules[1]
.exports
.push(make_export("totallyUnused", 40, 55));
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 graph = build_graph_with_referenced_export(
&[
("/tmp/test/src/entry.ts", true),
("/tmp/test/src/helpers.ts", false),
],
1,
"usedByReachable",
10,
28,
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(),
"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,
is_side_effect_used: false,
visibility: VisibilityTag::Internal,
expected_unused_reason: None,
span: Span::new(10, 30),
references: vec![],
reference_paths: Vec::new(),
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,
is_side_effect_used: false,
visibility: VisibilityTag::Beta,
expected_unused_reason: None,
span: Span::new(10, 30),
references: vec![],
reference_paths: Vec::new(),
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,
is_side_effect_used: false,
visibility: VisibilityTag::Alpha,
expected_unused_reason: None,
span: Span::new(10, 30),
references: vec![],
reference_paths: Vec::new(),
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");
}
fn make_export_entry(module_idx: usize, file_id: u32) -> ExportEntry {
ExportEntry {
module_idx,
path: PathBuf::from(format!("f{file_id}.ts")),
file_id: FileId(file_id),
span_start: 0,
is_type_only: false,
}
}
fn reference_partition(
entries: &[ExportEntry],
export_name: &str,
graph: &ModuleGraph,
) -> Vec<Vec<usize>> {
let mut union_find = UnionFind::new(entries.len());
let mut importer_to_entries: FxHashMap<FileId, Vec<usize>> = FxHashMap::default();
for (i, entry) in entries.iter().enumerate() {
let idx = entry.file_id.0 as usize;
if idx >= graph.reverse_deps.len() {
continue;
}
for &importer in &graph.reverse_deps[idx] {
if !entry.connects_importer(graph, importer, export_name) {
continue;
}
importer_to_entries.entry(importer).or_default().push(i);
}
}
for members in importer_to_entries.values() {
if let Some((&first, rest)) = members.split_first() {
for &other in rest {
union_find.union(first, other);
}
}
}
let entry_file_ids: FxHashSet<FileId> = entries.iter().map(|e| e.file_id).collect();
for (i, entry) in entries.iter().enumerate() {
let idx = entry.file_id.0 as usize;
if idx >= graph.reverse_deps.len() {
continue;
}
for &importer in &graph.reverse_deps[idx] {
if !entry.connects_importer(graph, importer, export_name) {
continue;
}
if entry_file_ids.contains(&importer)
&& let Some(j) = entries.iter().position(|e| e.file_id == importer)
{
union_find.union(i, j);
}
}
}
union_find.components()
}
fn normalize_components(mut components: Vec<Vec<usize>>) -> Vec<Vec<usize>> {
for comp in &mut components {
comp.sort_unstable();
}
components.sort_unstable();
components
}
#[test]
fn partition_direct_import_index_matches_position_scan() {
let files: Vec<_> = (0..4)
.map(|file_id| DiscoveredFile {
id: FileId(file_id),
path: PathBuf::from(format!("f{file_id}.ts")),
size_bytes: 0,
})
.collect();
let named_import = |target: FileId| ResolvedImport {
info: ImportInfo {
source: format!("./f{}", target.0),
imported_name: ImportedName::Named("shared".to_string()),
local_name: "shared".to_string(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: Span::default(),
source_span: Span::default(),
},
target: ResolveResult::InternalModule(target),
};
let resolved_modules: Vec<_> = files
.iter()
.map(|file| ResolvedModule {
file_id: file.id,
path: file.path.clone(),
resolved_imports: match file.id.0 {
1 => vec![named_import(FileId(0)), named_import(FileId(2))],
3 => vec![named_import(FileId(0))],
_ => Vec::new(),
},
..Default::default()
})
.collect();
let graph = ModuleGraph::build(&resolved_modules, &[], &files);
let entries = vec![
make_export_entry(0, 0),
make_export_entry(1, 1),
make_export_entry(2, 1),
make_export_entry(3, 2),
make_export_entry(4, 3),
];
let actual = normalize_components(partition_by_common_importer(&entries, "shared", &graph));
let reference = normalize_components(reference_partition(&entries, "shared", &graph));
assert_eq!(
actual, reference,
"map-based partition must match the position()-scan reference"
);
assert_eq!(actual, vec![vec![0, 1, 3, 4], vec![2]]);
}
}