use rustc_hash::FxHashMap;
use super::namespace_indexes::{
NamespacePropagationIndexes, NamespaceReferenceRoutes, ReachableNamespaceExports,
};
use super::narrowing::{
NamespaceMarkContext, ReferenceDedup, ReferenceSite,
create_synthetic_exports_for_star_re_exports_at_site, mark_all_exports_referenced_at_site,
mark_member_exports_referenced_at_site,
};
use super::re_exports::ExposedNamespaceTargets;
use super::types::{ReferenceKind, ReferencePathId, ReferencePathInterner};
use super::{ExportNamespace, ModuleGraph};
use fallow_types::discover::FileId;
use fallow_types::extract::ModuleLoadMechanism;
enum CreditKind {
Member(String),
AllExports,
}
struct PendingCredit {
target_module_idx: usize,
namespace: ExportNamespace,
kind: CreditKind,
consumer_file_id: FileId,
import_span: oxc_span::Span,
path: Option<ReferencePathId>,
}
pub(super) fn propagate_namespace_re_exports(
graph: &mut ModuleGraph,
indexes: &NamespacePropagationIndexes<'_>,
exposed_namespace_targets: &ExposedNamespaceTargets,
reference_paths: &mut ReferencePathInterner,
) {
let ns_edges: Vec<(FileId, FileId, String, bool)> = graph
.modules
.iter()
.flat_map(|m| {
let barrel_file = m.file_id;
m.re_exports.iter().filter_map(move |re| {
if re.imported_name == "*" && re.exported_name != "*" {
Some((
barrel_file,
re.source_file,
re.exported_name.clone(),
re.is_type_only,
))
} else {
None
}
})
})
.collect();
if ns_edges.is_empty() {
return;
}
let mut pending: Vec<PendingCredit> = Vec::new();
let credits_unseen_consumers = !exposed_namespace_targets.is_empty()
|| graph
.modules
.iter()
.any(super::types::ModuleNode::is_entry_point);
for (barrel_file_id, source_file_id, exported_name, is_type_only) in &ns_edges {
let Some(target_module_idx) = module_index_for_file(graph, *source_file_id) else {
continue;
};
let namespaces: &[ExportNamespace] = if *is_type_only {
&[ExportNamespace::Type]
} else {
&[ExportNamespace::Type, ExportNamespace::Value]
};
for &namespace in namespaces {
if !credits_unseen_consumers && !indexes.has_consumers_in(namespace) {
continue;
}
let reachable = indexes.enumerate_reachable_barrels(
graph,
*barrel_file_id,
exported_name,
namespace,
);
let routes = reachable.intern_routes(
*source_file_id,
ModuleLoadMechanism::EsModule,
reference_paths,
);
for export in reachable.iter().filter(|export| {
exposes_namespace_object(
graph,
exposed_namespace_targets,
export.file_id,
&export.exported_name,
)
}) {
let path = routes.entry_path(export, reference_paths);
pending.push(PendingCredit {
target_module_idx,
namespace,
kind: CreditKind::AllExports,
consumer_file_id: export.file_id,
import_span: oxc_span::Span::default(),
path,
});
}
let context = ConsumerCreditContext {
indexes,
seed_barrel_file: *barrel_file_id,
target_module_idx,
namespace,
};
collect_consumer_credits(&context, &reachable, &routes, &mut pending, reference_paths);
}
}
apply_pending_credits(graph, &pending);
}
fn exposes_namespace_object(
graph: &ModuleGraph,
exposed_namespace_targets: &ExposedNamespaceTargets,
file_id: FileId,
exported_name: &str,
) -> bool {
exposed_namespace_targets.exposes_name(file_id, exported_name)
|| graph
.modules
.get(file_id.0 as usize)
.is_some_and(super::types::ModuleNode::is_entry_point)
}
fn module_index_for_file(graph: &ModuleGraph, file_id: FileId) -> Option<usize> {
let idx = file_id.0 as usize;
(idx < graph.modules.len()).then_some(idx)
}
struct ConsumerCreditContext<'indexes, 'modules> {
indexes: &'indexes NamespacePropagationIndexes<'modules>,
seed_barrel_file: FileId,
target_module_idx: usize,
namespace: ExportNamespace,
}
fn collect_consumer_credits(
context: &ConsumerCreditContext<'_, '_>,
reachable: &ReachableNamespaceExports,
routes: &NamespaceReferenceRoutes,
pending: &mut Vec<PendingCredit>,
reference_paths: &mut ReferencePathInterner,
) {
for export in reachable.iter() {
for indexed in context
.indexes
.consumers_for(export.file_id, &export.exported_name)
{
let consumer = indexed.consumer;
let import = indexed.import;
if consumer.file_id == context.seed_barrel_file {
continue;
}
if !indexed.namespaces.contains(context.namespace) {
continue;
}
let path = routes.consumer_path(export, indexed, reference_paths);
let consumer_local = import.info.local_name.as_str();
let whole_object = consumer
.whole_object_uses
.iter()
.any(|n| n == consumer_local);
if whole_object {
pending.push(PendingCredit {
target_module_idx: context.target_module_idx,
namespace: context.namespace,
kind: CreditKind::AllExports,
consumer_file_id: consumer.file_id,
import_span: import.info.span,
path,
});
continue;
}
for access in consumer.member_accesses.iter() {
if access.object != consumer_local {
continue;
}
pending.push(PendingCredit {
target_module_idx: context.target_module_idx,
namespace: context.namespace,
kind: CreditKind::Member(access.member.clone()),
consumer_file_id: consumer.file_id,
import_span: import.info.span,
path,
});
}
}
}
}
fn apply_pending_credits(graph: &mut ModuleGraph, pending: &[PendingCredit]) {
type GroupKey = (
usize,
FileId,
oxc_span::Span,
Option<ReferencePathId>,
ExportNamespace,
);
let mut groups: FxHashMap<GroupKey, GroupState> = FxHashMap::default();
for credit in pending {
let key = (
credit.target_module_idx,
credit.consumer_file_id,
credit.import_span,
credit.path,
credit.namespace,
);
let entry = groups.entry(key).or_default();
match &credit.kind {
CreditKind::Member(name) => {
if !entry.whole_object {
entry.members.push(name.clone());
}
}
CreditKind::AllExports => {
entry.whole_object = true;
entry.members.clear();
}
}
}
let mut dedup = ReferenceDedup::default();
let effective_exports = &graph.effective_exports;
let modules = &mut graph.modules;
for ((target_module_idx, consumer_file_id, import_span, path, namespace), state) in groups {
let module = &mut modules[target_module_idx];
let site = ReferenceSite::exact(consumer_file_id, import_span, path);
let mut mark = NamespaceMarkContext {
module_id: module.file_id,
site,
kind: ReferenceKind::NamespaceImport,
namespace,
effective_exports,
dedup: &mut dedup,
};
if state.whole_object {
mark_all_exports_referenced_at_site(&mut module.exports, &mut mark);
} else {
let found = mark_member_exports_referenced_at_site(
&mut module.exports,
&state.members,
&mut mark,
);
create_synthetic_exports_for_star_re_exports_at_site(
&mut module.exports,
&module.re_exports,
site,
&state.members,
&found,
namespace,
);
}
}
}
#[derive(Default)]
struct GroupState {
members: Vec<String>,
whole_object: bool,
}
#[cfg(test)]
mod tests {
use rustc_hash::FxHashSet;
use super::*;
use crate::graph::ModuleGraph;
use crate::resolve::{
ResolveResult, ResolvedImport, ResolvedModule, ResolvedReExport,
ResolvedReplacedModuleTarget,
};
use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
use fallow_types::extract::{
ExportInfo, ExportName, ImportInfo, ImportedName, MemberAccess, ReExportInfo, VisibilityTag,
};
use std::path::PathBuf;
fn discovered_file(id: u32, path: &str, size: u64) -> DiscoveredFile {
DiscoveredFile {
id: FileId(id),
path: PathBuf::from(path),
size_bytes: size,
}
}
fn named_export(name: &str) -> ExportInfo {
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: oxc_span::Span::new(0, 10),
members: vec![],
is_side_effect_used: false,
super_class: None,
}
}
fn named_import_from(source: &str, name: &str, target: FileId) -> ResolvedImport {
named_import_with_mechanism(source, name, target, false)
}
fn named_import_with_mechanism(
source: &str,
name: &str,
target: FileId,
commonjs: bool,
) -> ResolvedImport {
ResolvedImport {
info: ImportInfo {
source: source.to_string(),
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: oxc_span::Span::new(0, 10),
source_span: oxc_span::Span::default(),
},
target: if commonjs {
ResolveResult::CommonJsInternalModule(target)
} else {
ResolveResult::InternalModule(target)
},
}
}
fn side_effect_import(source: &str, target: FileId, span: oxc_span::Span) -> ResolvedImport {
ResolvedImport {
info: ImportInfo {
source: source.to_string(),
imported_name: ImportedName::SideEffect,
local_name: String::new(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span,
source_span: oxc_span::Span::default(),
},
target: ResolveResult::InternalModule(target),
}
}
fn ns_re_export(source: &str, alias: &str, target: FileId) -> ResolvedReExport {
ResolvedReExport {
info: ReExportInfo {
source: source.to_string(),
imported_name: "*".to_string(),
exported_name: alias.to_string(),
is_type_only: false,
span: oxc_span::Span::new(0, 10),
statement_span: oxc_span::Span::new(0, 0),
source_span: oxc_span::Span::new(0, 0),
},
target: ResolveResult::InternalModule(target),
}
}
fn named_re_export(source: &str, name: &str, target: FileId) -> ResolvedReExport {
ResolvedReExport {
info: ReExportInfo {
source: source.to_string(),
imported_name: name.to_string(),
exported_name: name.to_string(),
is_type_only: false,
span: oxc_span::Span::new(0, 10),
statement_span: oxc_span::Span::new(0, 0),
source_span: oxc_span::Span::new(0, 0),
},
target: ResolveResult::InternalModule(target),
}
}
fn star_re_export(source: &str, target: FileId) -> ResolvedReExport {
ResolvedReExport {
info: ReExportInfo {
source: source.to_string(),
imported_name: "*".to_string(),
exported_name: "*".to_string(),
is_type_only: false,
span: oxc_span::Span::new(0, 10),
statement_span: oxc_span::Span::new(0, 0),
source_span: oxc_span::Span::new(0, 0),
},
target: ResolveResult::InternalModule(target),
}
}
fn namespace_coverage_graph(commonjs_consumer: bool) -> ModuleGraph {
let files = vec![
discovered_file(0, "/project/consumer.test.ts", 100),
discovered_file(1, "/project/barrel.ts", 50),
discovered_file(2, "/project/source.ts", 50),
discovered_file(3, "/project/alternate.ts", 50),
];
let modules = vec![
ResolvedModule {
file_id: FileId(0),
path: files[0].path.clone(),
resolved_imports: vec![
named_import_with_mechanism("./barrel", "Ns", FileId(1), commonjs_consumer),
side_effect_import("./alternate", FileId(3), oxc_span::Span::new(20, 30)),
],
member_accesses: vec![MemberAccess {
object: "Ns".to_string(),
member: "used".to_string(),
}]
.into(),
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: files[1].path.clone(),
re_exports: vec![ns_re_export("./source", "Ns", FileId(2))],
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: files[2].path.clone(),
exports: vec![named_export("used")].into(),
..Default::default()
},
ResolvedModule {
file_id: FileId(3),
path: files[3].path.clone(),
resolved_imports: vec![side_effect_import(
"./source",
FileId(2),
oxc_span::Span::new(0, 10),
)],
..Default::default()
},
];
let test_entries = vec![EntryPoint {
path: files[0].path.clone(),
source: EntryPointSource::TestFile,
}];
ModuleGraph::build_with_reachability_roots_and_replacements(
&modules,
&[ResolvedReplacedModuleTarget {
source_file: FileId(0),
target_file: FileId(1),
}],
&test_entries,
&[],
&test_entries,
&files,
)
}
fn namespace_diamond_graph(mask_both_branches: bool) -> ModuleGraph {
let files: Vec<_> = (0..7)
.map(|id| discovered_file(id, &format!("/project/file-{id}.ts"), 50))
.collect();
let modules = vec![
ResolvedModule {
file_id: FileId(0),
path: files[0].path.clone(),
resolved_imports: vec![
named_import_from("./outer", "Ns", FileId(1)),
side_effect_import("./alternate", FileId(6), oxc_span::Span::new(20, 30)),
],
member_accesses: vec![MemberAccess {
object: "Ns".to_string(),
member: "used".to_string(),
}]
.into(),
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: files[1].path.clone(),
re_exports: vec![
star_re_export("./left", FileId(2)),
star_re_export("./right", FileId(3)),
],
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: files[2].path.clone(),
re_exports: vec![star_re_export("./namespace", FileId(4))],
..Default::default()
},
ResolvedModule {
file_id: FileId(3),
path: files[3].path.clone(),
re_exports: vec![star_re_export("./namespace", FileId(4))],
..Default::default()
},
ResolvedModule {
file_id: FileId(4),
path: files[4].path.clone(),
re_exports: vec![ns_re_export("./source", "Ns", FileId(5))],
..Default::default()
},
ResolvedModule {
file_id: FileId(5),
path: files[5].path.clone(),
exports: vec![named_export("used")].into(),
..Default::default()
},
ResolvedModule {
file_id: FileId(6),
path: files[6].path.clone(),
resolved_imports: vec![side_effect_import(
"./source",
FileId(5),
oxc_span::Span::new(0, 10),
)],
..Default::default()
},
];
let test_entries = vec![EntryPoint {
path: files[0].path.clone(),
source: EntryPointSource::TestFile,
}];
let mut replacements = vec![ResolvedReplacedModuleTarget {
source_file: FileId(0),
target_file: FileId(2),
}];
if mask_both_branches {
replacements.push(ResolvedReplacedModuleTarget {
source_file: FileId(0),
target_file: FileId(3),
});
}
ModuleGraph::build_with_reachability_roots_and_replacements(
&modules,
&replacements,
&test_entries,
&[],
&test_entries,
&files,
)
}
#[test]
fn mocked_namespace_barrel_stays_uncovered_when_target_has_an_alternate_route() {
let graph = namespace_coverage_graph(false);
let export = &graph.modules[2].exports[0];
assert!(graph.modules[2].is_test_reachable());
assert_eq!(
graph.reference_path_hops(export, 0),
vec![
(FileId(2), ModuleLoadMechanism::EsModule),
(FileId(1), ModuleLoadMechanism::EsModule),
]
);
assert!(!graph.is_test_reference_covered(export, 0));
}
#[test]
fn commonjs_namespace_consumer_retains_its_exact_load_path() {
let graph = namespace_coverage_graph(true);
let export = &graph.modules[2].exports[0];
assert_eq!(
graph.reference_path_hops(export, 0),
vec![
(FileId(2), ModuleLoadMechanism::EsModule),
(FileId(1), ModuleLoadMechanism::CommonJsRequire),
]
);
assert!(graph.is_test_reference_covered(export, 0));
}
#[test]
fn compact_namespace_routes_survive_graph_cache_roundtrip() {
let graph = namespace_coverage_graph(false);
let encoded = postcard::to_allocvec(&graph).expect("encode namespace route graph");
let decoded: ModuleGraph =
postcard::from_bytes(&encoded).expect("decode namespace route graph");
let export = &decoded.modules[2].exports[0];
assert_eq!(
decoded.reference_path_hops(export, 0),
vec![
(FileId(2), ModuleLoadMechanism::EsModule),
(FileId(1), ModuleLoadMechanism::EsModule),
]
);
assert!(!decoded.is_test_reference_covered(export, 0));
assert_eq!(decoded.reference_routes.graphs.len(), 1);
assert_eq!(decoded.reference_routes.nodes.len(), 2);
}
#[test]
fn namespace_diamond_is_covered_when_one_route_remains_active() {
let graph = namespace_diamond_graph(false);
let export = &graph.modules[5].exports[0];
assert_eq!(export.references.len(), 1);
assert!(graph.is_test_reference_covered(export, 0));
}
#[test]
fn namespace_diamond_is_uncovered_when_every_route_is_masked() {
let graph = namespace_diamond_graph(true);
let export = &graph.modules[5].exports[0];
assert!(graph.modules[5].is_test_reachable());
assert_eq!(export.references.len(), 1);
assert!(!graph.is_test_reference_covered(export, 0));
}
#[test]
fn issue_324_simple_namespace_re_export_credits_target_members() {
let files = vec![
discovered_file(0, "/project/main.ts", 100),
discovered_file(1, "/project/barrel.ts", 50),
discovered_file(2, "/project/source-module.ts", 50),
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/project/main.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved_modules = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/main.ts"),
resolved_imports: vec![named_import_from("./barrel", "MyNamespace", FileId(1))],
member_accesses: vec![MemberAccess {
object: "MyNamespace".to_string(),
member: "someExportedSymbol".to_string(),
}]
.into(),
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/project/barrel.ts"),
re_exports: vec![ns_re_export("./source-module", "MyNamespace", FileId(2))],
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: PathBuf::from("/project/source-module.ts"),
exports: vec![
named_export("someExportedSymbol"),
named_export("anotherSymbol"),
]
.into(),
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
let someexp = graph.modules[2]
.exports
.iter()
.find(|e| e.name.to_string() == "someExportedSymbol")
.unwrap();
assert!(
!someexp.references.is_empty(),
"someExportedSymbol should be credited via namespace re-export"
);
let unused = graph.modules[2]
.exports
.iter()
.find(|e| e.name.to_string() == "anotherSymbol")
.unwrap();
assert!(
unused.references.is_empty(),
"anotherSymbol stays unreferenced when only someExportedSymbol is accessed"
);
}
#[test]
fn explicit_export_shadows_namespace_object_forwarded_by_star() {
let files: Vec<_> = ["main", "namespace", "source", "explicit", "outer"]
.iter()
.enumerate()
.map(|(id, name)| discovered_file(id as u32, &format!("/project/{name}.ts"), 50))
.collect();
let entry_points = vec![EntryPoint {
path: files[0].path.clone(),
source: EntryPointSource::PackageJsonMain,
}];
let resolved_modules = vec![
ResolvedModule {
file_id: FileId(0),
path: files[0].path.clone(),
resolved_imports: vec![named_import_from("./outer", "N", FileId(4))],
member_accesses: vec![MemberAccess {
object: "N".to_string(),
member: "used".to_string(),
}]
.into(),
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: files[1].path.clone(),
re_exports: vec![ns_re_export("./source", "N", FileId(2))],
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: files[2].path.clone(),
exports: vec![named_export("used")].into(),
..Default::default()
},
ResolvedModule {
file_id: FileId(3),
path: files[3].path.clone(),
exports: vec![named_export("N")].into(),
..Default::default()
},
ResolvedModule {
file_id: FileId(4),
path: files[4].path.clone(),
re_exports: vec![
star_re_export("./namespace", FileId(1)),
named_re_export("./explicit", "N", FileId(3)),
],
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
let target = graph.modules[2]
.exports
.iter()
.find(|export| export.name.to_string() == "used")
.expect("namespace target export");
assert!(
target.references.is_empty(),
"shadowed namespace object must not receive consumer member credit"
);
}
#[test]
fn type_only_consumer_credits_namespace_member_in_type_space() {
let files = vec![
discovered_file(0, "/project/main.ts", 100),
discovered_file(1, "/project/barrel.ts", 50),
discovered_file(2, "/project/source.ts", 50),
];
let entry_points = vec![EntryPoint {
path: files[0].path.clone(),
source: EntryPointSource::PackageJsonMain,
}];
let mut import = named_import_from("./barrel", "N", FileId(1));
import.info.is_type_only = true;
let resolved_modules = vec![
ResolvedModule {
file_id: FileId(0),
path: files[0].path.clone(),
resolved_imports: vec![import],
member_accesses: vec![MemberAccess {
object: "N".to_string(),
member: "used".to_string(),
}]
.into(),
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: files[1].path.clone(),
re_exports: vec![ns_re_export("./source", "N", FileId(2))],
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: files[2].path.clone(),
exports: vec![named_export("used")].into(),
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
let references = &graph.modules[2].exports[0].references;
assert_eq!(references.len(), 1);
assert_eq!(references[0].namespace, ExportNamespace::Type);
}
#[test]
fn type_only_namespace_consumer_credits_type_and_value_backed_members() {
let files = vec![
discovered_file(0, "/project/main.ts", 100),
discovered_file(1, "/project/barrel.ts", 50),
discovered_file(2, "/project/source.ts", 50),
];
let mut import = named_import_from("./barrel", "N", FileId(1));
import.info.is_type_only = true;
let mut interface = named_export("Foo");
interface.is_type_only = true;
let graph = ModuleGraph::build(
&[
ResolvedModule {
file_id: FileId(0),
path: files[0].path.clone(),
resolved_imports: vec![import],
member_accesses: vec![
MemberAccess {
object: "N".to_string(),
member: "Foo".to_string(),
},
MemberAccess {
object: "N".to_string(),
member: "bar".to_string(),
},
]
.into(),
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: files[1].path.clone(),
re_exports: vec![ns_re_export("./source", "N", FileId(2))],
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: files[2].path.clone(),
exports: vec![interface, named_export("bar")].into(),
..Default::default()
},
],
&[EntryPoint {
path: files[0].path.clone(),
source: EntryPointSource::PackageJsonMain,
}],
&files,
);
for export in &graph.modules[2].exports {
assert_eq!(export.references.len(), 1);
assert_eq!(export.references[0].namespace, ExportNamespace::Type);
}
}
#[test]
fn issue_324_multi_hop_named_re_export_chain_credits_target() {
let files = vec![
discovered_file(0, "/project/main.ts", 100),
discovered_file(1, "/project/outer-barrel.ts", 50),
discovered_file(2, "/project/inner-barrel.ts", 50),
discovered_file(3, "/project/source.ts", 50),
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/project/main.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved_modules = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/main.ts"),
resolved_imports: vec![named_import_from("./outer-barrel", "Ns", FileId(1))],
member_accesses: vec![MemberAccess {
object: "Ns".to_string(),
member: "used".to_string(),
}]
.into(),
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/project/outer-barrel.ts"),
re_exports: vec![named_re_export("./inner-barrel", "Ns", FileId(2))],
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: PathBuf::from("/project/inner-barrel.ts"),
re_exports: vec![ns_re_export("./source", "Ns", FileId(3))],
..Default::default()
},
ResolvedModule {
file_id: FileId(3),
path: PathBuf::from("/project/source.ts"),
exports: vec![named_export("used"), named_export("unused")].into(),
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
let used = graph.modules[3]
.exports
.iter()
.find(|e| e.name.to_string() == "used")
.unwrap();
assert!(
!used.references.is_empty(),
"used should be credited through two-hop barrel chain"
);
let still_unused = graph.modules[3]
.exports
.iter()
.find(|e| e.name.to_string() == "unused")
.unwrap();
assert!(
still_unused.references.is_empty(),
"unused stays flagged across the chain"
);
}
#[test]
fn issue_324_whole_object_use_credits_all_target_exports() {
let files = vec![
discovered_file(0, "/project/main.ts", 100),
discovered_file(1, "/project/barrel.ts", 50),
discovered_file(2, "/project/source.ts", 50),
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/project/main.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved_modules = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/main.ts"),
resolved_imports: vec![named_import_from("./barrel", "Ns", FileId(1))],
whole_object_uses: vec!["Ns".to_string()].into(),
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/project/barrel.ts"),
re_exports: vec![ns_re_export("./source", "Ns", FileId(2))],
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: PathBuf::from("/project/source.ts"),
exports: vec![named_export("a"), named_export("b"), named_export("c")].into(),
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
for export in &graph.modules[2].exports {
assert!(
!export.references.is_empty(),
"{} should be credited under whole-object use",
export.name
);
}
}
#[test]
fn issue_324_entry_point_barrel_credits_all_target_exports() {
let files = vec![
discovered_file(0, "/project/index.ts", 100),
discovered_file(1, "/project/source.ts", 50),
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/project/index.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved_modules = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/index.ts"),
re_exports: vec![ns_re_export("./source", "Public", FileId(1))],
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/project/source.ts"),
exports: vec![named_export("apiOne"), named_export("apiTwo")].into(),
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
for export in &graph.modules[1].exports {
assert!(
!export.references.is_empty(),
"{} should be credited because the namespace re-export is exposed externally",
export.name
);
}
}
#[test]
fn issue_324_synthetic_export_propagates_through_star_chain_on_target() {
let files = vec![
discovered_file(0, "/project/main.ts", 100),
discovered_file(1, "/project/barrel.ts", 50),
discovered_file(2, "/project/source-barrel.ts", 50),
discovered_file(3, "/project/impl.ts", 50),
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/project/main.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved_modules = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/main.ts"),
resolved_imports: vec![named_import_from("./barrel", "Ns", FileId(1))],
member_accesses: vec![MemberAccess {
object: "Ns".to_string(),
member: "deepMember".to_string(),
}]
.into(),
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/project/barrel.ts"),
re_exports: vec![ns_re_export("./source-barrel", "Ns", FileId(2))],
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: PathBuf::from("/project/source-barrel.ts"),
re_exports: vec![ResolvedReExport {
info: ReExportInfo {
source: "./impl".to_string(),
imported_name: "*".to_string(),
exported_name: "*".to_string(),
is_type_only: false,
span: oxc_span::Span::new(0, 10),
statement_span: oxc_span::Span::new(0, 0),
source_span: oxc_span::Span::new(0, 0),
},
target: ResolveResult::InternalModule(FileId(3)),
}],
..Default::default()
},
ResolvedModule {
file_id: FileId(3),
path: PathBuf::from("/project/impl.ts"),
exports: vec![named_export("deepMember"), named_export("unused")].into(),
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
let deep = graph.modules[3]
.exports
.iter()
.find(|e| e.name.to_string() == "deepMember")
.unwrap();
assert!(
!deep.references.is_empty(),
"deepMember should be credited via synthetic stub plus Phase 4 star chain"
);
let unused = graph.modules[3]
.exports
.iter()
.find(|e| e.name.to_string() == "unused")
.unwrap();
assert!(
unused.references.is_empty(),
"non-accessed members in the chain target stay flagged"
);
}
#[test]
fn issue_324_unused_binding_skipped() {
let files = vec![
discovered_file(0, "/project/main.ts", 100),
discovered_file(1, "/project/barrel.ts", 50),
discovered_file(2, "/project/source.ts", 50),
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/project/main.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let mut consumer_unused = FxHashSet::default();
consumer_unused.insert("Ns".to_string());
let resolved_modules = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/main.ts"),
resolved_imports: vec![named_import_from("./barrel", "Ns", FileId(1))],
unused_import_bindings: consumer_unused,
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/project/barrel.ts"),
re_exports: vec![ns_re_export("./source", "Ns", FileId(2))],
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: PathBuf::from("/project/source.ts"),
exports: vec![named_export("a")].into(),
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
let a = graph.modules[2]
.exports
.iter()
.find(|e| e.name.to_string() == "a")
.unwrap();
assert!(
a.references.is_empty(),
"unused namespace binding should not credit any target export"
);
}
#[test]
fn issue_324_renamed_local_binding_still_credits_members() {
let files = vec![
discovered_file(0, "/project/main.ts", 100),
discovered_file(1, "/project/barrel.ts", 50),
discovered_file(2, "/project/source.ts", 50),
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/project/main.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let renamed_import = ResolvedImport {
info: ImportInfo {
source: "./barrel".to_string(),
imported_name: ImportedName::Named("Foo".to_string()),
local_name: "MyFoo".to_string(),
is_type_only: false,
is_type_only_star: false,
from_style: false,
span: oxc_span::Span::new(0, 10),
source_span: oxc_span::Span::default(),
},
target: ResolveResult::InternalModule(FileId(1)),
};
let resolved_modules = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/main.ts"),
resolved_imports: vec![renamed_import],
member_accesses: vec![MemberAccess {
object: "MyFoo".to_string(),
member: "used".to_string(),
}]
.into(),
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/project/barrel.ts"),
re_exports: vec![ns_re_export("./source", "Foo", FileId(2))],
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: PathBuf::from("/project/source.ts"),
exports: vec![named_export("used"), named_export("unused")].into(),
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
let used = graph.modules[2]
.exports
.iter()
.find(|e| e.name.to_string() == "used")
.unwrap();
assert!(
!used.references.is_empty(),
"used credited via the renamed local binding MyFoo.used"
);
let unused = graph.modules[2]
.exports
.iter()
.find(|e| e.name.to_string() == "unused")
.unwrap();
assert!(
unused.references.is_empty(),
"unused stays flagged; renamed-local narrowing is precise"
);
}
#[test]
fn issue_324_plain_export_star_not_credited_by_this_pass() {
let files = vec![
discovered_file(0, "/project/main.ts", 100),
discovered_file(1, "/project/barrel.ts", 50),
discovered_file(2, "/project/source.ts", 50),
];
let entry_points = vec![EntryPoint {
path: PathBuf::from("/project/main.ts"),
source: EntryPointSource::PackageJsonMain,
}];
let resolved_modules = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from("/project/main.ts"),
resolved_imports: vec![named_import_from("./barrel", "fromSource", FileId(1))],
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from("/project/barrel.ts"),
re_exports: vec![ResolvedReExport {
info: ReExportInfo {
source: "./source".to_string(),
imported_name: "*".to_string(),
exported_name: "*".to_string(),
is_type_only: false,
span: oxc_span::Span::new(0, 10),
statement_span: oxc_span::Span::new(0, 0),
source_span: oxc_span::Span::new(0, 0),
},
target: ResolveResult::InternalModule(FileId(2)),
}],
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: PathBuf::from("/project/source.ts"),
exports: vec![named_export("fromSource"), named_export("untouched")].into(),
..Default::default()
},
];
let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
let from_source = graph.modules[2]
.exports
.iter()
.find(|e| e.name.to_string() == "fromSource")
.unwrap();
assert!(
!from_source.references.is_empty(),
"fromSource credited via existing Phase 4 star-re-export path"
);
let untouched = graph.modules[2]
.exports
.iter()
.find(|e| e.name.to_string() == "untouched")
.unwrap();
assert!(
untouched.references.is_empty(),
"Phase 2c does not over-credit unrelated exports under plain export-star"
);
}
fn ambient_star_namespace_chain_graph() -> ModuleGraph {
let paths = [
"/project/main.ts",
"/project/shim.ts",
"/project/barrel.ts",
"/project/sub.ts",
"/project/deep.ts",
];
let files: Vec<_> = paths
.iter()
.enumerate()
.map(|(id, path)| discovered_file(id as u32, path, 50))
.collect();
let entry_points = vec![EntryPoint {
path: PathBuf::from(paths[0]),
source: EntryPointSource::PackageJsonMain,
}];
let default_export = || ExportInfo {
name: ExportName::Default,
..named_export("default")
};
let resolved_modules = vec![
ResolvedModule {
file_id: FileId(0),
path: PathBuf::from(paths[0]),
resolved_imports: vec![side_effect_import(
"./shim",
FileId(1),
oxc_span::Span::new(0, 10),
)],
..Default::default()
},
ResolvedModule {
file_id: FileId(1),
path: PathBuf::from(paths[1]),
resolved_imports: vec![ResolvedImport {
info: ImportInfo {
source: "./barrel".to_string(),
imported_name: ImportedName::Namespace,
local_name: String::new(),
is_type_only: true,
is_type_only_star: false,
from_style: false,
span: oxc_span::Span::new(0, 10),
source_span: oxc_span::Span::default(),
},
target: ResolveResult::InternalModule(FileId(2)),
}],
..Default::default()
},
ResolvedModule {
file_id: FileId(2),
path: PathBuf::from(paths[2]),
re_exports: vec![ns_re_export("./sub", "sub", FileId(3))],
..Default::default()
},
ResolvedModule {
file_id: FileId(3),
path: PathBuf::from(paths[3]),
re_exports: vec![star_re_export("./deep", FileId(4))],
exports: vec![named_export("subOne"), default_export()].into(),
..Default::default()
},
ResolvedModule {
file_id: FileId(4),
path: PathBuf::from(paths[4]),
exports: vec![named_export("deepOne"), default_export()].into(),
..Default::default()
},
];
ModuleGraph::build(&resolved_modules, &entry_points, &files)
}
#[test]
fn issue_2357_ambient_star_target_exposes_its_namespace_re_exports() {
let graph = ambient_star_namespace_chain_graph();
let export = |module: usize, name: &str| {
graph.modules[module]
.exports
.iter()
.find(|e| e.name.to_string() == name)
.unwrap_or_else(|| panic!("module {module} must export `{name}`"))
};
for name in ["subOne", "default"] {
let shapes: Vec<_> = export(3, name)
.references
.iter()
.map(|r| (r.kind, r.namespace, r.from_file))
.collect();
assert!(
shapes.contains(&(
ReferenceKind::NamespaceImport,
ExportNamespace::Value,
FileId(2)
)),
"sub.ts:{name} is exposed through barrel.ts's namespace object, found {shapes:?}"
);
}
assert!(
export(4, "deepOne")
.references
.iter()
.any(|r| r.kind == ReferenceKind::ReExport && r.from_file == FileId(3)),
"deep.ts:deepOne is credited through sub.ts's `export *` chain"
);
assert!(
export(4, "default").references.is_empty(),
"sub.ts's plain `export *` never forwards deep.ts's default"
);
}
}