From abb5bbfb9905a21c9ac2bbfaa606c8c33e42eb35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 14 Sep 2026 01:06:47 +0200 Subject: [PATCH 1/5] compile: prune unused re-export subgraphs (#10180) --- changelog.d/10180-prune-unused-reexports.md | 1 + .../perry/src/commands/compile/bootstrap.rs | 6 +- .../perry/src/commands/compile/build_cache.rs | 22 +- .../src/commands/compile/collect_modules.rs | 21 +- .../compile/collect_modules/reexport_prune.rs | 264 ++++++++++ .../reexport_prune/forwarding.rs | 175 +++++++ .../collect_modules/reexport_prune/scan.rs | 324 ++++++++++++ .../reexport_prune/side_effects.rs | 164 ++++++ .../commands/compile/collect_modules/walk.rs | 112 +++-- .../src/commands/compile/run_pipeline.rs | 13 + crates/perry/src/commands/compile/types.rs | 2 + .../tests/source_graph_export_regressions.rs | 2 + .../issue_10180.rs | 465 ++++++++++++++++++ docs/src/cli/flags.md | 28 ++ 14 files changed, 1547 insertions(+), 52 deletions(-) create mode 100644 changelog.d/10180-prune-unused-reexports.md create mode 100644 crates/perry/src/commands/compile/collect_modules/reexport_prune.rs create mode 100644 crates/perry/src/commands/compile/collect_modules/reexport_prune/forwarding.rs create mode 100644 crates/perry/src/commands/compile/collect_modules/reexport_prune/scan.rs create mode 100644 crates/perry/src/commands/compile/collect_modules/reexport_prune/side_effects.rs create mode 100644 crates/perry/tests/source_graph_export_regressions/issue_10180.rs diff --git a/changelog.d/10180-prune-unused-reexports.md b/changelog.d/10180-prune-unused-reexports.md new file mode 100644 index 0000000000..e7bc4b539e --- /dev/null +++ b/changelog.d/10180-prune-unused-reexports.md @@ -0,0 +1 @@ +Perry now postpones unused named and wildcard re-exports during module collection when package sideEffects declarations prove their static dependency trees safe to omit. Pure import/export-list barrels are normalized to equivalent re-exports. Export demand grows to a fixed point across later importers and barrel chains; imports used by module code, dynamic-import namespaces, live bindings, and effectful or uncertain dependencies remain available. The compile summary reports omitted modules. Set PERRY_NO_REEXPORT_PRUNE=1 for an unpruned comparison, or PERRY_COLLECT_ONLY=1 to write the collection audit without generating code. diff --git a/crates/perry/src/commands/compile/bootstrap.rs b/crates/perry/src/commands/compile/bootstrap.rs index 7642feccc3..66530ef3de 100644 --- a/crates/perry/src/commands/compile/bootstrap.rs +++ b/crates/perry/src/commands/compile/bootstrap.rs @@ -211,6 +211,7 @@ pub(super) fn rerun_collect_with_class_field_types( ctx.cross_module_class_field_types = field_map; ctx.cross_module_class_accessors = accessor_map; ctx.native_modules.clear(); + ctx.reexport_pruner = Default::default(); visited.clear(); *next_class_id = 1; collect_modules( @@ -972,10 +973,11 @@ pub(super) fn run_post_collect_preflight( match format { OutputFormat::Text => { println!( - "Found {} module(s): {} native, {} JavaScript", + "Found {} module(s): {} native, {} JavaScript, {} pruned as unreferenced side-effect-free re-exports", total_modules, ctx.native_modules.len(), - ctx.js_modules.len() + ctx.js_modules.len(), + ctx.reexport_pruner.pruned ); } OutputFormat::Json => {} diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index a027c99175..2aa0e7c1b6 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -127,6 +127,7 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ // cache entries. "PERRY_TARGET_CPU", "PERRY_NO_AUTO_OPTIMIZE", + "PERRY_NO_REEXPORT_PRUNE", "PERRY_DISABLE_WELL_KNOWN", "PERRY_FORCE_WELL_KNOWN", // Both switches change native-vs-JavaScript module routing and therefore @@ -589,6 +590,21 @@ impl BuildCacheProbe { if verify_files(&manifest.sources).is_err() { return miss("source"); } + // A newly added nested package.json can override a pruning contract. + // Verifying only previously existing files misses that graph change. + let config_paths = + config_inputs_for(&manifest.sources, &self.project_root, &self.cache_root) + .iter() + .map(|path| absolute_identity(path)) + .collect::>(); + let recorded_config_paths = manifest + .config_inputs + .iter() + .map(|input| input.path.clone()) + .collect::>(); + if config_paths != recorded_config_paths { + return miss("config-paths"); + } if verify_files(&manifest.config_inputs).is_err() { return miss("config"); } @@ -838,7 +854,11 @@ fn eligibility(args: &CompileArgs, project_root: &Path) -> Result<(), String> { if args.type_check { return Err("type-check".to_string()); } - if args.print_hir || args.trace.is_some() || args.focus.is_some() { + if args.print_hir + || args.trace.is_some() + || args.focus.is_some() + || std::env::var("PERRY_COLLECT_ONLY").ok().as_deref() == Some("1") + { return Err("diagnostic-mode".to_string()); } if args.typed_feedback_profile.is_some() || args.typed_feedback_sites.is_some() { diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 67c7490997..b3221d4b08 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -41,6 +41,7 @@ mod import_meta_resolve; mod json_module; mod native_addon; mod parse_error; +pub(crate) mod reexport_prune; mod script_string; mod static_require_transform; #[cfg(test)] @@ -540,6 +541,9 @@ fn collect_module_one( let ast_module = defined_module.as_ref().unwrap_or(ast_module); let resolved_module = import_meta_resolve::resolve_static(ast_module, &canonical, ctx)?; let ast_module = resolved_module.as_ref().unwrap_or(ast_module); + let forwarding_module = + reexport_prune::normalize_forwarding_barrel(ast_module, entry_path, ctx); + let ast_module = forwarding_module.as_ref().unwrap_or(ast_module); let file_loader_sources = file_loader_import_sources(ast_module); let source_file_path = canonical.to_string_lossy().to_string(); @@ -1796,8 +1800,10 @@ fn collect_module_one( } } + ctx.reexport_pruner.imports(&hir_module.imports); + // Process re-exports - for export in &hir_module.exports { + for (export_index, export) in hir_module.exports.iter().enumerate() { let source = match export { perry_hir::Export::ReExport { source, .. } => Some(source), perry_hir::Export::ExportAll { source } => Some(source), @@ -1924,7 +1930,18 @@ fn collect_module_one( } match kind { - ModuleKind::NativeCompiled => pending.push(source_path), + ModuleKind::NativeCompiled => { + if reexport_prune::record( + ctx, + &canonical, + export_index, + export, + &resolved_path, + &source_path, + ) { + pending.push(source_path); + } + } ModuleKind::Interpreted => { // JS runtime (V8) support was removed, so interpreted // node_modules dependencies are not followed. A direct diff --git a/crates/perry/src/commands/compile/collect_modules/reexport_prune.rs b/crates/perry/src/commands/compile/collect_modules/reexport_prune.rs new file mode 100644 index 0000000000..ec729cb5cd --- /dev/null +++ b/crates/perry/src/commands/compile/collect_modules/reexport_prune.rs @@ -0,0 +1,264 @@ +//! Collection-time, monotone export demand. Ordinary imports are retained. An +//! unused re-export is postponed only when its entire static dependency tree +//! is covered by package sideEffects contracts. Later importers can reactivate +//! it; HIR edges are removed only after every collection root has settled. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use perry_hir::{Export, Import, ImportSpecifier}; + +use super::super::CompilationContext; + +mod forwarding; +mod scan; +mod side_effects; +pub(super) use forwarding::normalize as normalize_forwarding_barrel; + +#[derive(Clone, Default)] +struct Demand { + all: bool, + names: HashSet, +} + +impl Demand { + fn all() -> Self { + Self { + all: true, + names: HashSet::new(), + } + } + + fn name(name: &str) -> Self { + Self { + all: false, + names: HashSet::from([name.to_owned()]), + } + } + + fn contains(&self, name: &str) -> bool { + self.all || self.names.contains(name) + } +} + +struct Edge { + from: PathBuf, + index: usize, + target: PathBuf, + source_path: PathBuf, + export: Export, + safe: bool, + active: bool, +} + +#[derive(Default)] +pub(crate) struct ReexportPruner { + demands: HashMap, + edges: Vec, + scan: scan::Scanner, + pub(crate) pruned: usize, +} + +fn enabled() -> bool { + std::env::var("PERRY_NO_REEXPORT_PRUNE").ok().as_deref() != Some("1") +} + +impl ReexportPruner { + fn demand(&mut self, path: &Path, incoming: Demand) -> bool { + let current = self.demands.entry(path.to_owned()).or_default(); + if current.all { + return false; + } + if incoming.all { + *current = incoming; + return true; + } + let before = current.names.len(); + current.names.extend(incoming.names); + current.names.len() != before + } + + pub(crate) fn root(&mut self, path: &Path) { + self.demand(path, Demand::all()); + } + + pub(super) fn implicit_root(&mut self, path: &Path) { + if !self.demands.contains_key(path) { + self.root(path); + } + } + + pub(crate) fn imports(&mut self, imports: &[Import]) { + if !enabled() { + return; + } + for import in imports { + if import.type_only || import.runtime_erased { + continue; + } + let Some(path) = &import.resolved_path else { + continue; + }; + let mut demand = Demand::default(); + // A dynamic import exposes the complete namespace, even when the + // same source also has a named static import. require interop and + // namespaces likewise cannot be narrowed to named imports. + demand.all = import.is_dynamic || import.is_dynamic_target || import.is_adopted_require; + for spec in &import.specifiers { + match spec { + ImportSpecifier::Named { imported, .. } => { + demand.names.insert(imported.clone()); + } + ImportSpecifier::Default { .. } => { + demand.names.insert("default".into()); + } + ImportSpecifier::Namespace { .. } => demand.all = true, + } + } + self.demand(Path::new(path), demand); + } + } + + fn forwarded(&mut self, edge: &Edge, ctx: &mut CompilationContext) -> Option { + let needed = self.demands.get(&edge.from).cloned().unwrap_or_default(); + // An effectful edge retains its complete original export surface, so + // namespace getters cannot refer to missing transitive bindings. + let force = !edge.safe || edge.from == edge.target; + match &edge.export { + Export::ReExport { + imported, exported, .. + } => (force || needed.contains(exported)).then(|| Demand::name(imported)), + Export::NamespaceReExport { name, .. } => { + (force || needed.contains(name)).then(Demand::all) + } + Export::ExportAll { .. } => { + if force { + return Some(Demand::all()); + } + if needed.all { + // `export *` forwards neither default nor erased TS + // declarations, even to a consumer of the full namespace. + return self + .scan + .may_have_star_exports(&edge.source_path, ctx) + .then(Demand::all); + } + let names: HashSet<_> = needed + .names + .into_iter() + .filter(|name| { + name != "default" && self.scan.might_export(&edge.source_path, name, ctx) + }) + .collect(); + (!names.is_empty()).then_some(Demand { all: false, names }) + } + Export::Named { .. } => None, + } + } +} + +/// Return whether the ordinary DFS should visit this target immediately. +pub(super) fn record( + ctx: &mut CompilationContext, + from: &Path, + index: usize, + export: &Export, + target: &Path, + source_path: &Path, +) -> bool { + if !enabled() { + return true; + } + let mut state = std::mem::take(&mut ctx.reexport_pruner); + let safe = state.scan.declared_pure(from) && state.scan.can_drop_tree(source_path, ctx); + let mut edge = Edge { + from: from.to_owned(), + index, + target: target.to_owned(), + source_path: source_path.to_owned(), + export: export.clone(), + safe, + active: false, + }; + if let Some(demand) = state.forwarded(&edge, ctx) { + state.demand(target, demand); + edge.active = true; + } + let active = edge.active; + state.edges.push(edge); + ctx.reexport_pruner = state; + active +} + +/// Revisit postponed edges after later direct/dynamic imports add demand. +/// This also forwards new names across already-active barrel edges. Demand +/// only grows, so cycles converge without depending on DFS visitation order. +pub(super) fn settle(ctx: &mut CompilationContext) -> Vec { + let mut state = std::mem::take(&mut ctx.reexport_pruner); + let mut edges = std::mem::take(&mut state.edges); + let mut pending = Vec::new(); + loop { + let mut changed = false; + for edge in &mut edges { + if let Some(demand) = state.forwarded(edge, ctx) { + changed |= state.demand(&edge.target, demand); + if !edge.active { + edge.active = true; + pending.push(edge.source_path.clone()); + } + } + } + if !changed { + break; + } + } + state.edges = edges; + ctx.reexport_pruner = state; + pending +} + +pub(crate) fn finish(ctx: &mut CompilationContext) { + let mut state = std::mem::take(&mut ctx.reexport_pruner); + let mut removed: HashMap> = HashMap::new(); + let mut omitted = HashSet::new(); + for edge in &state.edges { + if !edge.active { + removed + .entry(edge.from.clone()) + .or_default() + .insert(edge.index); + state.scan.static_tree(&edge.source_path, &mut omitted); + } + } + for (path, indices) in removed { + if let Some(module) = ctx.native_modules.get_mut(&path) { + let mut index = 0; + module.exports.retain(|_| { + let keep = !indices.contains(&index); + index += 1; + keep + }); + } + } + state.pruned = omitted + .iter() + .filter(|p| !ctx.native_modules.contains_key(*p)) + .count(); + ctx.reexport_pruner = state; +} + +pub(crate) fn write_graph(ctx: &mut CompilationContext, entry: &Path) -> anyhow::Result<()> { + super::super::init_order::classify_eager_modules(ctx, entry); + let modules: Vec<_> = ctx.native_modules.iter().map(|(path, module)| { + serde_json::json!({ + "path": path, + "init": if module.init_kind == perry_hir::ModuleInitKind::Eager { "eager" } else { "deferred" }, + }) + }).collect(); + let graph = serde_json::json!({"modules": modules, "pruned": ctx.reexport_pruner.pruned}); + std::fs::write( + ctx.cache_dir.join("module-graph.json"), + serde_json::to_vec_pretty(&graph)?, + )?; + Ok(()) +} diff --git a/crates/perry/src/commands/compile/collect_modules/reexport_prune/forwarding.rs b/crates/perry/src/commands/compile/collect_modules/reexport_prune/forwarding.rs new file mode 100644 index 0000000000..f6c02b93c4 --- /dev/null +++ b/crates/perry/src/commands/compile/collect_modules/reexport_prune/forwarding.rs @@ -0,0 +1,175 @@ +//! Published barrels (notably Remeda) spell re-exports as imports followed by +//! `export { local as public }`. Normalize only modules containing imports, +//! export lists and empty statements, with an entirely side-effect-free static +//! dependency tree. No imported binding can then be used by module code, and +//! reordering import/re-export groups cannot move an effectful dependency. + +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use swc_ecma_ast as ast; + +use super::CompilationContext; + +pub(crate) fn normalize( + module: &ast::Module, + path: &Path, + ctx: &mut CompilationContext, +) -> Option { + if !super::enabled() + || !module.body.iter().all(|item| { + matches!( + item, + ast::ModuleItem::ModuleDecl( + ast::ModuleDecl::Import(_) + | ast::ModuleDecl::ExportNamed(_) + | ast::ModuleDecl::ExportAll(_) + ) | ast::ModuleItem::Stmt(ast::Stmt::Empty(_)) + ) + }) + { + return None; + } + // Ordinary imports apply packageAliases before resolving; re-export + // declarations currently do not. Do not normalize across that distinction. + if module.body.iter().any(|item| { + matches!(item, + ast::ModuleItem::ModuleDecl(ast::ModuleDecl::Import(import)) + if import.phase != ast::ImportPhase::Evaluation + || ctx.package_aliases.contains_key(import.src.value.to_string_lossy().as_ref()) + ) + }) { + return None; + } + + let mut exports: HashMap> = HashMap::new(); + for item in &module.body { + let ast::ModuleItem::ModuleDecl(ast::ModuleDecl::ExportNamed(export)) = item else { + continue; + }; + if export.src.is_some() || export.type_only { + continue; + } + for spec in &export.specifiers { + let ast::ExportSpecifier::Named(named) = spec else { + return None; + }; + if named.is_type_only { + continue; + } + let ast::ModuleExportName::Ident(local) = &named.orig else { + return None; + }; + let mut single = export.clone(); + let mut named = named.clone(); + // Renaming orig to the imported name below must not rename the + // public export when the original list omitted `as public`. + named.exported = Some(named.exported.clone().unwrap_or_else(|| named.orig.clone())); + single.specifiers = vec![ast::ExportSpecifier::Named(named)]; + exports + .entry(local.sym.to_string()) + .or_default() + .push(single); + } + } + let candidates: HashSet = module + .body + .iter() + .filter_map(|item| match item { + ast::ModuleItem::ModuleDecl(ast::ModuleDecl::Import(import)) if !import.type_only => { + Some(import) + } + _ => None, + }) + .flat_map(|import| import.specifiers.iter()) + .filter_map(|spec| match spec { + ast::ImportSpecifier::Named(named) + if !named.is_type_only && exports.contains_key(named.local.sym.as_ref()) => + { + Some(named.local.sym.to_string()) + } + _ => None, + }) + .collect(); + if candidates.is_empty() { + return None; + } + + let mut state = std::mem::take(&mut ctx.reexport_pruner); + let safe = state.scan.can_drop_tree(path, ctx); + ctx.reexport_pruner = state; + if !safe { + return None; + } + + let mut result = module.clone(); + result.body.clear(); + for item in &module.body { + match item { + ast::ModuleItem::ModuleDecl(ast::ModuleDecl::Import(import)) if !import.type_only => { + let mut remaining = import.clone(); + let mut forwarded = Vec::new(); + remaining.specifiers.retain(|spec| { + let ast::ImportSpecifier::Named(named) = spec else { + return true; + }; + if named.is_type_only || !candidates.contains(named.local.sym.as_ref()) { + return true; + } + let imported = named + .imported + .clone() + .unwrap_or_else(|| ast::ModuleExportName::Ident(named.local.clone())); + for template in &exports[named.local.sym.as_ref()] { + let mut export = template.clone(); + export.span = import.span; + export.src = Some(import.src.clone()); + export.with = import.with.clone(); + let ast::ExportSpecifier::Named(spec) = &mut export.specifiers[0] else { + unreachable!(); + }; + spec.orig = imported.clone(); + forwarded.push(ast::ModuleItem::ModuleDecl(ast::ModuleDecl::ExportNamed( + export, + ))); + } + false + }); + // Preserve genuine bare imports, even under sideEffects:false. + if !remaining.specifiers.is_empty() || forwarded.is_empty() { + result + .body + .push(ast::ModuleItem::ModuleDecl(ast::ModuleDecl::Import( + remaining, + ))); + } + result.body.extend(forwarded); + } + ast::ModuleItem::ModuleDecl(ast::ModuleDecl::ExportNamed(export)) + if export.src.is_none() && !export.type_only => + { + let mut remaining = export.clone(); + remaining.specifiers.retain(|spec| match spec { + ast::ExportSpecifier::Named(named) if !named.is_type_only => { + match &named.orig { + ast::ModuleExportName::Ident(local) => { + !candidates.contains(local.sym.as_ref()) + } + _ => true, + } + } + _ => true, + }); + if !remaining.specifiers.is_empty() { + result + .body + .push(ast::ModuleItem::ModuleDecl(ast::ModuleDecl::ExportNamed( + remaining, + ))); + } + } + _ => result.body.push(item.clone()), + } + } + Some(result) +} diff --git a/crates/perry/src/commands/compile/collect_modules/reexport_prune/scan.rs b/crates/perry/src/commands/compile/collect_modules/reexport_prune/scan.rs new file mode 100644 index 0000000000..0acdb7ecf3 --- /dev/null +++ b/crates/perry/src/commands/compile/collect_modules/reexport_prune/scan.rs @@ -0,0 +1,324 @@ +//! Lightweight AST summaries, never HIR lowering or code generation. A +//! negative export lookup is valid only after traversing every export-star +//! branch; unknown syntax/resolution keeps the edge. Static effect proofs +//! include dependencies outside the declaring package (and terminate cycles). + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use swc_ecma_ast as ast; +use swc_ecma_visit::{Visit, VisitWith}; + +use super::super::super::CompilationContext; +use super::super::import_helpers::cached_resolve_import_with_lexical_base; +use super::side_effects::Contracts; + +#[derive(Clone, Default)] +struct Summary { + names: HashSet, + stars: Vec, + dependencies: Vec, + unknown_exports: bool, + unknown_dependencies: bool, +} + +#[derive(Default)] +pub(super) struct Scanner { + summaries: HashMap, + droppable: HashMap, + exports: HashMap<(PathBuf, Option), bool>, + contracts: Contracts, +} + +impl Scanner { + pub(super) fn declared_pure(&mut self, path: &Path) -> bool { + self.contracts.is_pure(path) + } + + fn summary(&mut self, path: &Path, ctx: &mut CompilationContext) -> Summary { + let canonical = path.canonicalize().unwrap_or_else(|_| path.to_owned()); + if let Some(summary) = self.summaries.get(&canonical) { + return summary.clone(); + } + // Omitted source still participates in the build-cache proof: edits + // can add a requested export or an effectful dependency. Its enclosing + // package manifests are fingerprinted by config_inputs_for as well. + ctx.resolve_inputs.insert(canonical.clone()); + let mut result = Summary::default(); + let parsed = std::fs::read_to_string(path).ok().and_then(|source| { + if super::super::super::cjs_wrap::is_commonjs(&source) { + return None; + } + perry_parser::parse_typescript(&source, &path.to_string_lossy()).ok() + }); + if let Some(module) = parsed { + let defined = ctx.parsed_defines.apply(&module); + let module = defined.as_ref().unwrap_or(&module); + let mut opaque = OpaqueLoads::default(); + module.visit_with(&mut opaque); + result.unknown_dependencies = opaque.0; + for item in &module.body { + let ast::ModuleItem::ModuleDecl(decl) = item else { + continue; + }; + let mut dependency = None; + let mut star = false; + match decl { + ast::ModuleDecl::Import(import) if !import.type_only => { + if import.specifiers.is_empty() + || import.specifiers.iter().any( + |s| !matches!(s, ast::ImportSpecifier::Named(n) if n.is_type_only), + ) + { + dependency = Some(&import.src); + } + } + ast::ModuleDecl::ExportAll(export) if !export.type_only => { + dependency = Some(&export.src); + star = true; + } + ast::ModuleDecl::ExportNamed(export) if !export.type_only => { + let mut runtime = export.specifiers.is_empty(); + for spec in &export.specifiers { + let name = match spec { + ast::ExportSpecifier::Named(n) if !n.is_type_only => { + Some(export_name(n.exported.as_ref().unwrap_or(&n.orig))) + } + ast::ExportSpecifier::Namespace(n) => Some(export_name(&n.name)), + ast::ExportSpecifier::Default(_) => { + result.unknown_exports = true; + None + } + _ => None, + }; + if let Some(name) = name { + result.names.insert(name); + runtime = true; + } + } + if runtime { + dependency = export.src.as_ref(); + } + } + ast::ModuleDecl::ExportDecl(export) => match &export.decl { + ast::Decl::Fn(f) => { + result.names.insert(f.ident.sym.to_string()); + } + ast::Decl::Class(c) => { + result.names.insert(c.ident.sym.to_string()); + } + ast::Decl::Var(v) => { + for decl in &v.decls { + pattern_names(&decl.name, &mut result.names); + } + } + ast::Decl::TsEnum(e) => { + result.names.insert(e.id.sym.to_string()); + } + ast::Decl::TsInterface(_) | ast::Decl::TsTypeAlias(_) => {} + _ => result.unknown_exports = true, + }, + ast::ModuleDecl::ExportDefaultDecl(_) + | ast::ModuleDecl::ExportDefaultExpr(_) => { + result.names.insert("default".into()); + } + ast::ModuleDecl::TsImportEquals(_) | ast::ModuleDecl::TsExportAssignment(_) => { + result.unknown_exports = true; + result.unknown_dependencies = true; + } + _ => {} + } + if let Some(source) = dependency { + let source = source.value.to_string_lossy(); + let source = if matches!(decl, ast::ModuleDecl::Import(_)) { + ctx.package_aliases + .get(source.as_ref()) + .cloned() + .unwrap_or_else(|| source.into_owned()) + } else { + source.into_owned() + }; + if let Some(resolved) = + cached_resolve_import_with_lexical_base(&source, path, &canonical, ctx) + { + if resolved.kind == perry_hir::ModuleKind::NativeRust { + result.unknown_dependencies = true; + result.unknown_exports |= star; + } else if !super::super::super::is_declaration_file( + &resolved.canonical_path, + ) { + result.dependencies.push(resolved.source_path.clone()); + if star { + result.stars.push(resolved.source_path); + } + } + } else { + result.unknown_dependencies = true; + result.unknown_exports |= star; + } + } + } + } else { + result.unknown_exports = true; + result.unknown_dependencies = true; + } + self.summaries.insert(canonical, result.clone()); + result + } + + pub(super) fn might_export( + &mut self, + path: &Path, + name: &str, + ctx: &mut CompilationContext, + ) -> bool { + self.exports_match(path, Some(name), ctx) + } + + pub(super) fn may_have_star_exports( + &mut self, + path: &Path, + ctx: &mut CompilationContext, + ) -> bool { + self.exports_match(path, None, ctx) + } + + fn exports_match( + &mut self, + path: &Path, + name: Option<&str>, + ctx: &mut CompilationContext, + ) -> bool { + let key = (path.to_owned(), name.map(str::to_owned)); + if let Some(result) = self.exports.get(&key) { + return *result; + } + let mut seen = HashSet::new(); + let mut work = vec![path.to_owned()]; + let mut found = false; + while let Some(path) = work.pop() { + let canonical = path.canonicalize().unwrap_or_else(|_| path.clone()); + if !seen.insert(canonical) { + continue; + } + let summary = self.summary(&path, ctx); + let matches = match name { + Some(name) => summary.names.contains(name), + None => summary.names.iter().any(|name| name != "default"), + }; + if summary.unknown_exports || matches { + found = true; + break; + } + work.extend(summary.stars); + } + self.exports.insert(key, found); + found + } + + pub(super) fn can_drop_tree(&mut self, path: &Path, ctx: &mut CompilationContext) -> bool { + let root = path.canonicalize().unwrap_or_else(|_| path.to_owned()); + if let Some(result) = self.droppable.get(&root) { + return *result; + } + let mut seen = HashSet::new(); + let mut work = vec![path.to_owned()]; + while let Some(path) = work.pop() { + let canonical = path.canonicalize().unwrap_or_else(|_| path.clone()); + if !seen.insert(canonical.clone()) { + continue; + } + match self.droppable.get(&canonical) { + Some(true) => continue, + Some(false) => { + self.droppable.insert(root, false); + return false; + } + None => {} + } + if !self.declared_pure(&canonical) { + self.droppable.insert(root, false); + return false; + } + let summary = self.summary(&path, ctx); + if summary.unknown_dependencies { + self.droppable.insert(root, false); + return false; + } + work.extend(summary.dependencies); + } + // Only cache success for the whole explored set after checking all + // branches. Caching a partially visited cycle could hide an effect. + for path in seen { + self.droppable.insert(path, true); + } + true + } + + pub(super) fn static_tree(&self, root: &Path, seen: &mut HashSet) { + let mut work = vec![root.to_owned()]; + while let Some(path) = work.pop() { + let canonical = path.canonicalize().unwrap_or(path); + if !seen.insert(canonical.clone()) { + continue; + } + if let Some(summary) = self.summaries.get(&canonical) { + work.extend(summary.dependencies.iter().cloned()); + } + } + } +} + +fn export_name(name: &ast::ModuleExportName) -> String { + match name { + ast::ModuleExportName::Ident(i) => i.sym.to_string(), + ast::ModuleExportName::Str(s) => s.value.to_string_lossy().into_owned(), + } +} + +fn pattern_names(pattern: &ast::Pat, names: &mut HashSet) { + match pattern { + ast::Pat::Ident(i) => { + names.insert(i.id.sym.to_string()); + } + ast::Pat::Array(a) => { + for p in a.elems.iter().flatten() { + pattern_names(p, names); + } + } + ast::Pat::Object(o) => { + for p in &o.props { + match p { + ast::ObjectPatProp::KeyValue(p) => pattern_names(&p.value, names), + ast::ObjectPatProp::Assign(p) => { + names.insert(p.key.id.sym.to_string()); + } + ast::ObjectPatProp::Rest(p) => pattern_names(&p.arg, names), + } + } + } + ast::Pat::Assign(p) => pattern_names(&p.left, names), + ast::Pat::Rest(p) => pattern_names(&p.arg, names), + _ => {} + } +} + +#[derive(Default)] +struct OpaqueLoads(bool); + +impl Visit for OpaqueLoads { + fn visit_ident(&mut self, ident: &ast::Ident) { + // Aliased require/eval and CommonJS mixed with ESM cannot supply a + // complete static effect graph. Over-approximating these names is safe. + if matches!(ident.sym.as_ref(), "require" | "eval" | "module") { + self.0 = true; + } + } + + fn visit_jsx_element(&mut self, _: &ast::JSXElement) { + self.0 = true; + } + fn visit_jsx_fragment(&mut self, _: &ast::JSXFragment) { + self.0 = true; + } +} diff --git a/crates/perry/src/commands/compile/collect_modules/reexport_prune/side_effects.rs b/crates/perry/src/commands/compile/collect_modules/reexport_prune/side_effects.rs new file mode 100644 index 0000000000..b7a1a463e0 --- /dev/null +++ b/crates/perry/src/commands/compile/collect_modules/reexport_prune/side_effects.rs @@ -0,0 +1,164 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +#[derive(Default)] +pub(super) struct Contracts { + files: HashMap, + manifests: HashMap>, +} + +impl Contracts { + pub(super) fn is_pure(&mut self, path: &Path) -> bool { + if let Some(pure) = self.files.get(path) { + return *pure; + } + let pure = self.lookup(path); + self.files.insert(path.to_owned(), pure); + pure + } + + fn lookup(&mut self, path: &Path) -> bool { + // Stop at the owning package, never inherit a parent's contract across + // nested node_modules. Type-only package.json files inside dist/ may + // omit sideEffects; the owning package's declaration still applies. + let mut package_root = None; + let mut prefix = PathBuf::new(); + let mut components = path.components(); + while let Some(component) = components.next() { + prefix.push(component); + if component.as_os_str() == "node_modules" { + let Some(name) = components.next() else { + return false; + }; + prefix.push(name); + if name.as_os_str().to_string_lossy().starts_with('@') { + let Some(name) = components.next() else { + return false; + }; + prefix.push(name); + } + package_root = Some(prefix.clone()); + } + } + let Some(root) = package_root else { + return false; + }; + for dir in path.parent().into_iter().flat_map(Path::ancestors) { + let manifest = self.manifests.entry(dir.to_owned()).or_insert_with(|| { + std::fs::read(dir.join("package.json")) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + }); + if manifest.is_none() && dir.join("package.json").exists() { + return false; + } + if let Some(value) = manifest.as_ref().and_then(|v| v.get("sideEffects")) { + return match value { + serde_json::Value::Bool(false) => true, + serde_json::Value::Array(patterns) => { + let Ok(relative) = path.strip_prefix(dir) else { + return false; + }; + let relative = relative.to_string_lossy().replace('\\', "/"); + patterns.iter().all(|pattern| { + pattern + .as_str() + .is_some_and(|pattern| !may_match(pattern, &relative)) + }) + } + _ => false, + }; + } + if dir == root { + break; + } + } + false + } +} + +/// Standard *, ** and ? globs. Unsupported syntax is treated as matching, +/// including negation/extglobs/braces/classes: uncertainty must retain files. +fn may_match(pattern: &str, path: &str) -> bool { + if !pattern.is_ascii() + || !path.is_ascii() + || pattern.starts_with('/') + || pattern.contains(['!', '[', ']', '{', '}', '(', ')', '\\']) + { + return true; + } + let rooted = pattern.starts_with("./"); + let pattern = pattern.strip_prefix("./").unwrap_or(pattern); + let pattern = if rooted || pattern.contains('/') { + pattern.to_owned() + } else { + format!("**/{pattern}") + }; + glob( + &pattern.split('/').collect::>(), + &path.split('/').collect::>(), + ) +} + +fn glob(pattern: &[&str], path: &[&str]) -> bool { + let mut row = vec![false; path.len() + 1]; + row[0] = true; + for part in pattern { + let mut next = vec![false; path.len() + 1]; + if *part == "**" { + next[0] = row[0]; + } + for i in 1..=path.len() { + next[i] = if *part == "**" { + row[i] || next[i - 1] + } else { + row[i - 1] && segment(part.as_bytes(), path[i - 1].as_bytes()) + }; + } + row = next; + } + row[path.len()] +} + +fn segment(pattern: &[u8], text: &[u8]) -> bool { + // Dynamic programming bounds adversarial sequences of stars to O(n*m). + let mut row = vec![false; text.len() + 1]; + row[0] = true; + for c in pattern { + let mut next = vec![false; text.len() + 1]; + if *c == b'*' { + next[0] = row[0]; + } + for i in 1..=text.len() { + next[i] = if *c == b'*' { + row[i] || next[i - 1] + } else { + row[i - 1] && (*c == b'?' || *c == text[i - 1]) + }; + } + row = next; + } + row[text.len()] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn glob_contracts_fail_closed() { + for (pattern, path) in [ + ("*.css", "nested/a.css"), + ("./init.js", "init.js"), + ("**/init?.js", "deep/init1.js"), + ("**/*.js", "a.js"), + ("!*.js", "a.ts"), + ("[ab].js", "c.ts"), + ("?.js", "ä.js"), + ] { + assert!(may_match(pattern, path), "{pattern} {path}"); + } + assert!(!may_match("./init.js", "nested/init.js")); + assert!(!may_match("*.css", "a.js")); + } +} diff --git a/crates/perry/src/commands/compile/collect_modules/walk.rs b/crates/perry/src/commands/compile/collect_modules/walk.rs index e0ada64141..f41e52eb92 100644 --- a/crates/perry/src/commands/compile/collect_modules/walk.rs +++ b/crates/perry/src/commands/compile/collect_modules/walk.rs @@ -14,6 +14,7 @@ pub(crate) fn collect_modules( progress: &VerboseProgress, mut parse_cache: Option<&mut ParseCache>, ) -> Result<()> { + ctx.reexport_pruner.root(&entry_path.canonicalize()?); let mut states: HashMap = HashMap::new(); let mut stack = vec![WorkFrame::Enter(entry_path.clone())]; // Next.js wall 54 (part 2): a standalone `server.js` loads its page, route, @@ -47,61 +48,78 @@ pub(crate) fn collect_modules( } } } - while let Some(frame) = stack.pop() { - match frame { - WorkFrame::Enter(next_path) => { - let canonical = next_path.canonicalize().map_err(|e| { - anyhow!("Failed to canonicalize {}: {}", next_path.display(), e) - })?; + loop { + while let Some(frame) = stack.pop() { + match frame { + WorkFrame::Enter(next_path) => { + let canonical = next_path.canonicalize().map_err(|e| { + anyhow!("Failed to canonicalize {}: {}", next_path.display(), e) + })?; + // Worker/asset/standalone roots can enter outside ordinary + // import edges. Their full namespace must remain available. + ctx.reexport_pruner.implicit_root(&canonical); - if matches!( - states.get(&canonical), - Some(VisitState::InProgress | VisitState::Done) - ) { - continue; - } - if visited.contains(&canonical) { - states.insert(canonical, VisitState::Done); - continue; - } + if matches!( + states.get(&canonical), + Some(VisitState::InProgress | VisitState::Done) + ) { + continue; + } + if visited.contains(&canonical) { + states.insert(canonical, VisitState::Done); + continue; + } - states.insert(canonical.clone(), VisitState::InProgress); - visited.insert(canonical.clone()); - progress.record(ProgressSnapshot { - stage: "collect-module", - module_path: Some(&canonical), - visited: Some(visited.len()), - collected: Some(ctx.native_modules.len() + ctx.js_modules.len()), - ..Default::default() - }); + states.insert(canonical.clone(), VisitState::InProgress); + visited.insert(canonical.clone()); + progress.record(ProgressSnapshot { + stage: "collect-module", + module_path: Some(&canonical), + visited: Some(visited.len()), + collected: Some(ctx.native_modules.len() + ctx.js_modules.len()), + ..Default::default() + }); - let discovered = collect_module_one( - &next_path, - canonical.clone(), - ctx, - visited, - format, - target, - next_class_id, - progress, - parse_cache.as_deref_mut(), - )?; + let discovered = collect_module_one( + &next_path, + canonical.clone(), + ctx, + visited, + format, + target, + next_class_id, + progress, + parse_cache.as_deref_mut(), + )?; - if let Some(prepared) = discovered.finish { - stack.push(WorkFrame::Finish(prepared)); - } else { - states.insert(canonical, VisitState::Done); + if let Some(prepared) = discovered.finish { + stack.push(WorkFrame::Finish(prepared)); + } else { + states.insert(canonical, VisitState::Done); + } + for child in discovered.children.into_iter().rev() { + stack.push(WorkFrame::Enter(child)); + } } - for child in discovered.children.into_iter().rev() { - stack.push(WorkFrame::Enter(child)); + WorkFrame::Finish(prepared) => { + let canonical = prepared.canonical.clone(); + collect_module_finish( + prepared, + ctx, + visited, + target, + skip_transforms, + progress, + )?; + states.insert(canonical, VisitState::Done); } } - WorkFrame::Finish(prepared) => { - let canonical = prepared.canonical.clone(); - collect_module_finish(prepared, ctx, visited, target, skip_transforms, progress)?; - states.insert(canonical, VisitState::Done); - } } + let pending = reexport_prune::settle(ctx); + if pending.is_empty() { + break; + } + stack.extend(pending.into_iter().rev().map(WorkFrame::Enter)); } Ok(()) } diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index f57c5a8f96..2778dc4d25 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -934,7 +934,20 @@ pub fn run_with_parse_cache( } } + collect_modules::reexport_prune::finish(&mut ctx); run_post_collect_preflight(&args, &mut ctx, format)?; + if std::env::var("PERRY_COLLECT_ONLY").ok().as_deref() == Some("1") { + collect_modules::reexport_prune::write_graph(&mut ctx, &args.input.canonicalize()?)?; + return Ok(CompileResult { + output_path: ctx.cache_dir.join("audit.json"), + target: "module-graph".into(), + bundle_id: None, + is_dylib: false, + codegen_cache_stats: None, + link_cache_stats: None, + build_cache_stats: None, + }); + } // #2309: tree-shake the final module graph — prune unreachable // node_modules modules and re-raise any deferred refusal that survives. diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index 2bba63262c..1316022d9c 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -624,6 +624,7 @@ pub struct GeneratedAssetModule { pub struct CompilationContext { /// Native TypeScript modules to compile pub native_modules: BTreeMap, + pub(crate) reexport_pruner: super::collect_modules::reexport_prune::ReexportPruner, /// JavaScript modules to interpret via V8 pub js_modules: BTreeMap, /// Declaration sidecars discovered for resolved implementation files. @@ -1217,6 +1218,7 @@ impl CompilationContext { pub fn new(project_root: PathBuf) -> Self { Self { native_modules: BTreeMap::new(), + reexport_pruner: Default::default(), js_modules: BTreeMap::new(), declaration_sidecars: BTreeMap::new(), import_map: BTreeMap::new(), diff --git a/crates/perry/tests/source_graph_export_regressions.rs b/crates/perry/tests/source_graph_export_regressions.rs index c250983905..79c529b36d 100644 --- a/crates/perry/tests/source_graph_export_regressions.rs +++ b/crates/perry/tests/source_graph_export_regressions.rs @@ -956,5 +956,7 @@ fn mixed_type_and_value_specifier_import_keeps_runtime_edge() { } #[path = "source_graph_export_regressions/issue_10160.rs"] mod issue_10160; +#[path = "source_graph_export_regressions/issue_10180.rs"] +mod issue_10180; #[path = "source_graph_export_regressions/issue_10197.rs"] mod issue_10197; diff --git a/crates/perry/tests/source_graph_export_regressions/issue_10180.rs b/crates/perry/tests/source_graph_export_regressions/issue_10180.rs new file mode 100644 index 0000000000..ec46ddb5ae --- /dev/null +++ b/crates/perry/tests/source_graph_export_regressions/issue_10180.rs @@ -0,0 +1,465 @@ +//! Collection and runtime regressions for unused re-export pruning. Each +//! assertion reads the actual collection audit, so a successful executable +//! alone cannot turn the pruning checks into vacuous parity tests. + +use std::path::Path; +use std::process::Command; + +use super::{perry_bin, runtime_dir}; + +fn write(root: &Path, path: &str, text: &str) { + let path = root.join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); +} + +fn fixture(side_effects: Option) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + "package.json", + r#"{"perry":{"compilePackages":["fixture","external"],"allow":{"compilePackages":["fixture","external"]}}}"#, + ); + let mut package = serde_json::json!({"name":"fixture","type":"module","main":"index.js"}); + if let Some(value) = side_effects { + package["sideEffects"] = value; + } + write( + dir.path(), + "node_modules/fixture/package.json", + &package.to_string(), + ); + write( + dir.path(), + "node_modules/fixture/index.js", + "export { used } from './used.js'; export { unused } from './unused.js';", + ); + write( + dir.path(), + "node_modules/fixture/used.js", + "export const used = 42;", + ); + write( + dir.path(), + "node_modules/fixture/unused.js", + "export const unused = 99;", + ); + write( + dir.path(), + "main.ts", + "import { used } from 'fixture'; console.log(used);", + ); + dir +} + +fn compile(root: &Path, disabled: bool, collect_only: bool) -> (Vec, String) { + let cache = root.join(if disabled { "cache-off" } else { "cache-on" }); + let binary = root.join(if disabled { "main-off" } else { "main-on" }); + let mut command = Command::new(perry_bin()); + command + .current_dir(root) + .args(["compile", "main.ts", "--no-cache", "--cache-dir"]) + .arg(&cache) + .arg("-o") + .arg(&binary) + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env("PERRY_NO_REEXPORT_PRUNE", if disabled { "1" } else { "0" }) + .env("PERRY_COLLECT_ONLY", if collect_only { "1" } else { "0" }); + if !collect_only { + command.env("PERRY_RUNTIME_DIR", runtime_dir()); + } + let result = command.output().unwrap(); + let output = format!( + "{}\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + assert!(result.status.success(), "{output}"); + assert!( + output.contains("pruned as unreferenced side-effect-free re-exports"), + "{output}" + ); + let audit: serde_json::Value = + serde_json::from_slice(&std::fs::read(cache.join("audit.json")).unwrap()).unwrap(); + let paths = audit["modules"] + .as_array() + .unwrap() + .iter() + .map(|m| m["source"].as_str().unwrap().to_owned()) + .collect(); + if collect_only { + assert!( + !binary.exists(), + "collect-only must stop before code generation" + ); + return (paths, output); + } + let result = Command::new(binary).current_dir(root).output().unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + (paths, String::from_utf8(result.stdout).unwrap()) +} + +fn contains(paths: &[String], file: &str) -> bool { + paths.iter().any(|p| p.replace('\\', "/").ends_with(file)) +} + +#[test] +fn unused_sibling_is_never_collected_and_disable_switch_restores_it() { + let dir = fixture(Some(false.into())); + let (on, stdout) = compile(dir.path(), false, false); + assert_eq!(stdout, "42\n"); + assert!(contains(&on, "/fixture/used.js")); + assert!(!contains(&on, "/fixture/unused.js")); + let (off, stdout_off) = compile(dir.path(), true, false); + assert_eq!(stdout, stdout_off); + assert_eq!(off.len(), on.len() + 1); + assert!(contains(&off, "/fixture/unused.js")); +} + +#[test] +fn missing_or_effectful_contract_keeps_side_effect_order() { + for contract in [None, Some(true.into())] { + let dir = fixture(contract); + write( + dir.path(), + "node_modules/fixture/used.js", + "console.log('used'); export const used = 42;", + ); + write( + dir.path(), + "node_modules/fixture/unused.js", + "console.log('unused'); export const unused = 99;", + ); + write(dir.path(), "node_modules/fixture/index.js", "export { used } from './used.js'; export { unused } from './unused.js'; console.log('barrel');"); + let (paths, output) = compile(dir.path(), false, false); + assert!(contains(&paths, "/fixture/unused.js")); + assert_eq!(output, "used\nunused\nbarrel\n42\n"); + } +} + +#[test] +fn export_star_chain_and_renamed_binding_prune_transitive_siblings() { + let dir = fixture(Some(false.into())); + write( + dir.path(), + "node_modules/fixture/index.js", + "export * from './middle.js'; export * from './unused.js';", + ); + write( + dir.path(), + "node_modules/fixture/middle.js", + "export { used as answer } from './used.js'; export * from './extra.js';", + ); + write( + dir.path(), + "node_modules/fixture/extra.js", + "export const extra = 7;", + ); + write( + dir.path(), + "main.ts", + "import { answer } from 'fixture'; console.log(answer);", + ); + let (paths, output) = compile(dir.path(), false, false); + assert_eq!(output, "42\n"); + assert!(contains(&paths, "/fixture/middle.js")); + assert!(!contains(&paths, "/fixture/unused.js")); + assert!(!contains(&paths, "/fixture/extra.js")); +} + +#[test] +fn later_direct_import_restores_a_previously_unused_module() { + let dir = fixture(Some(false.into())); + write(dir.path(), "main.ts", "import { used } from 'fixture'; import { unused } from 'fixture/unused.js'; console.log(used, unused);"); + let (paths, output) = compile(dir.path(), false, false); + assert!(contains(&paths, "/fixture/unused.js")); + assert_eq!(output, "42 99\n"); +} + +#[test] +fn later_importer_adds_demand_to_already_visited_barrel() { + let dir = fixture(Some(false.into())); + write( + dir.path(), + "later.ts", + "import { unused } from 'fixture'; export const later = unused;", + ); + write(dir.path(), "main.ts", "import { used } from 'fixture'; import { later } from './later'; console.log(used, later);"); + let (paths, output) = compile(dir.path(), false, false); + assert!(contains(&paths, "/fixture/unused.js")); + assert_eq!(output, "42 99\n"); +} + +#[test] +fn dynamic_import_retains_namespace_and_defers_initialization() { + let dir = fixture(Some(false.into())); + write( + dir.path(), + "node_modules/fixture/unused.js", + "console.log('dynamic'); export const unused = 99;", + ); + write(dir.path(), "later.ts", "export async function load() { const ns = await import('fixture/unused.js'); console.log(ns.unused); }"); + write(dir.path(), "main.ts", "import { used } from 'fixture'; import { load } from './later'; console.log(used); load();"); + let (paths, output) = compile(dir.path(), false, false); + assert!(contains(&paths, "/fixture/unused.js")); + assert_eq!(output, "42\ndynamic\n99\n"); +} + +#[test] +fn dynamic_import_of_a_static_barrel_restores_all_exports() { + let dir = fixture(Some(false.into())); + write(dir.path(), "main.ts", "import { used } from 'fixture'; console.log(used); async function load() { const ns = await import('fixture'); console.log(ns.unused); } load();"); + let (paths, output) = compile(dir.path(), false, false); + assert!(contains(&paths, "/fixture/unused.js")); + assert_eq!(output, "42\n99\n"); +} + +#[test] +fn live_binding_is_read_after_mutation_through_reexport() { + let dir = fixture(Some(false.into())); + write( + dir.path(), + "node_modules/fixture/used.js", + "export let counter = 0; export function increment() { counter++; }", + ); + write( + dir.path(), + "node_modules/fixture/index.js", + "export { counter, increment } from './used.js'; export * from './unused.js';", + ); + write(dir.path(), "main.ts", "import { counter, increment } from 'fixture'; console.log(counter); increment(); console.log(counter);"); + let (paths, output) = compile(dir.path(), false, false); + assert!(!contains(&paths, "/fixture/unused.js")); + assert_eq!(output, "0\n1\n"); + assert_eq!(output, compile(dir.path(), true, false).1); +} + +#[test] +fn side_effect_globs_preserve_matches_and_external_effect_dependencies() { + let dir = fixture(Some(serde_json::json!(["**/effect*.js"]))); + write(dir.path(), "node_modules/fixture/index.js", "export { used } from './used.js'; export * from './unused.js'; export * from './effect.js';"); + write( + dir.path(), + "node_modules/fixture/effect.js", + "console.log('effect'); export const effect = 1;", + ); + let (paths, output) = compile(dir.path(), false, false); + assert_eq!(output, "effect\n42\n"); + assert!(contains(&paths, "/fixture/effect.js")); + assert!(!contains(&paths, "/fixture/unused.js")); + + write( + dir.path(), + "node_modules/external/package.json", + r#"{"name":"external","main":"index.js"}"#, + ); + write( + dir.path(), + "node_modules/external/index.js", + "console.log('external');", + ); + write( + dir.path(), + "node_modules/fixture/unused.js", + "import 'external'; export const unused = 99;", + ); + let (paths, output) = compile(dir.path(), false, false); + assert!(contains(&paths, "/external/index.js")); + assert!(contains(&paths, "/fixture/unused.js")); + assert_eq!(output, "external\neffect\n42\n"); +} + +#[test] +fn collect_only_handles_star_cycles_and_unknown_globs_conservatively() { + let dir = fixture(Some(false.into())); + write( + dir.path(), + "node_modules/fixture/index.js", + "export * from './cycle.js'; export * from './used.js'; export * from './unused.js';", + ); + write( + dir.path(), + "node_modules/fixture/cycle.js", + "export * from './index.js';", + ); + let (paths, _) = compile(dir.path(), false, true); + assert!(!contains(&paths, "/fixture/unused.js")); + write( + dir.path(), + "node_modules/fixture/package.json", + r#"{"name":"fixture","main":"index.js","sideEffects":["[ab].js"]}"#, + ); + let (paths, _) = compile(dir.path(), false, true); + assert!(contains(&paths, "/fixture/unused.js")); +} + +#[test] +fn cached_build_rechecks_omitted_sources_and_new_package_contracts() { + let dir = fixture(Some(false.into())); + write( + dir.path(), + "node_modules/fixture/index.js", + "export { used } from './used.js'; export * from './dead/index.js';", + ); + write( + dir.path(), + "node_modules/fixture/dead/index.js", + "console.log('restored'); export const unused = 99;", + ); + let binary = dir.path().join("cached-bin"); + let run = || { + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .args(["compile", "main.ts", "--cache-dir", "cache", "-o"]) + .arg(&binary) + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env("PERRY_RUNTIME_DIR", runtime_dir()) + .env("PERRY_NO_REEXPORT_PRUNE", "0") + .env_remove("PERRY_NO_CACHE") + .env_remove("PERRY_COLLECT_ONLY") + .output() + .unwrap(); + assert!( + compile.status.success(), + "{}", + String::from_utf8_lossy(&compile.stderr) + ); + let output = Command::new(&binary).output().unwrap(); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap() + }; + assert_eq!(run(), "42\n"); + assert_eq!(run(), "42\n"); + // No previously fingerprinted file changed: the new manifest must itself + // invalidate the cached proof that dead/index.js has no side effects. + write( + dir.path(), + "node_modules/fixture/dead/package.json", + r#"{"sideEffects":true}"#, + ); + assert_eq!(run(), "restored\n42\n"); + std::fs::remove_file(dir.path().join("node_modules/fixture/dead/package.json")).unwrap(); + assert_eq!(run(), "42\n"); + // Changing only an omitted source introduces an effectful dependency. + write( + dir.path(), + "node_modules/external/package.json", + r#"{"name":"external","main":"index.js"}"#, + ); + write( + dir.path(), + "node_modules/external/index.js", + "console.log('source changed');", + ); + write( + dir.path(), + "node_modules/fixture/dead/index.js", + "import 'external'; export const unused = 99;", + ); + assert_eq!(run(), "source changed\n42\n"); +} + +#[test] +fn namespace_keeps_values_but_star_does_not_forward_types_or_default() { + let dir = fixture(Some(false.into())); + write( + dir.path(), + "node_modules/fixture/index.js", + "export * from './used.js'; export * from './types.ts'; export * from './default.js';", + ); + write( + dir.path(), + "node_modules/fixture/types.ts", + "export interface Shape { x: number }; export type Alias = string;", + ); + write( + dir.path(), + "node_modules/fixture/default.js", + "export default 99;", + ); + write( + dir.path(), + "main.ts", + "import * as ns from 'fixture'; console.log(ns.used, Object.keys(ns).join(','));", + ); + let (paths, output) = compile(dir.path(), false, false); + assert!(!contains(&paths, "/fixture/types.ts")); + assert!(!contains(&paths, "/fixture/default.js")); + assert_eq!(output, "42 used\n"); + assert_eq!(output, compile(dir.path(), true, false).1); +} + +#[test] +fn imported_bindings_used_only_by_export_lists_are_reexports() { + let dir = fixture(Some(false.into())); + write(dir.path(), "node_modules/fixture/index.js", "import { used as value } from './used.js'; import { unused as other } from './unused.js'; export { value as used, other as unused };"); + let (paths, output) = compile(dir.path(), false, false); + assert_eq!(output, "42\n"); + assert!(!contains(&paths, "/fixture/unused.js")); + assert_eq!(output, compile(dir.path(), true, false).1); +} + +#[test] +fn forwarding_normalization_preserves_live_aliases() { + let dir = fixture(Some(false.into())); + write( + dir.path(), + "node_modules/fixture/used.js", + "export let counter = 0; export function increment() { counter++; }", + ); + write(dir.path(), "node_modules/fixture/index.js", "import { counter as localCounter, increment } from './used.js'; import { unused } from './unused.js'; export { localCounter as counter, increment, unused };"); + write(dir.path(), "main.ts", "import { counter, increment } from 'fixture'; console.log(counter); increment(); console.log(counter);"); + let (paths, output) = compile(dir.path(), false, false); + assert_eq!(output, "0\n1\n"); + assert!(!contains(&paths, "/fixture/unused.js")); + assert_eq!(output, compile(dir.path(), true, false).1); +} + +#[test] +fn imports_used_by_module_code_are_not_normalized() { + let dir = fixture(Some(false.into())); + write(dir.path(), "node_modules/fixture/index.js", "import { used } from './used.js'; import { unused } from './unused.js'; console.log(unused); export { used, unused };"); + let (paths, output) = compile(dir.path(), false, false); + assert_eq!(output, "99\n42\n"); + assert!(contains(&paths, "/fixture/unused.js")); +} + +#[test] +fn forwarding_barrels_keep_effectful_dependencies_and_bare_imports() { + let dir = fixture(Some(false.into())); + write(dir.path(), "node_modules/fixture/index.js", "import { used } from './used.js'; import { unused } from './unused.js'; import './bare.js'; export { used, unused };"); + write( + dir.path(), + "node_modules/fixture/bare.js", + "export const bare = 1;", + ); + let (paths, output) = compile(dir.path(), false, false); + assert_eq!(output, "42\n"); + assert!(!contains(&paths, "/fixture/unused.js")); + assert!(contains(&paths, "/fixture/bare.js")); + + write( + dir.path(), + "node_modules/external/package.json", + r#"{"name":"external","main":"index.js","sideEffects":true}"#, + ); + write( + dir.path(), + "node_modules/external/index.js", + "console.log('effect');", + ); + write( + dir.path(), + "node_modules/fixture/unused.js", + "import 'external'; export const unused = 99;", + ); + let (paths, output) = compile(dir.path(), false, false); + assert_eq!(output, "effect\n42\n"); + assert!(contains(&paths, "/fixture/unused.js")); + assert!(contains(&paths, "/external/index.js")); + assert_eq!(output, compile(dir.path(), true, false).1); +} diff --git a/docs/src/cli/flags.md b/docs/src/cli/flags.md index 4c4d226773..1474291175 100644 --- a/docs/src/cli/flags.md +++ b/docs/src/cli/flags.md @@ -618,3 +618,31 @@ coercion. Replay does not relax ownership, alias, lifetime or method-identity checks. Native-region verification requires a consumed fresh replay fact, a matching runtime guard and an explicit fallback/materialization record for every claimed profile selection. + +## Unused re-export collection + +Collection prunes unused `export { name } from`, `export * from`, and namespace +re-export edges by default when the exporting package declares the file free +of side effects and every static dependency of the omitted target has the same +guarantee. Perry honors `sideEffects: false` and arrays of `*`, `**`, and `?` +globs. Unsupported patterns, missing contracts, CommonJS, and unresolved +dependencies conservatively retain the edge. This pass does not remove direct +imports used by module code or individual declarations. An `import { x }; export { x }` +pair is first normalized to a re-export only in barrels containing imports and +export lists, whose entire static dependency tree has side-effect-free contracts. +Namespace and dynamic imports retain the +complete exported surface, and existing dynamic initialization stays deferred. + +Set `PERRY_NO_REEXPORT_PRUNE=1` to disable this collection pass for an A/B build. +The compile summary reports unique omitted modules in the proven static +dependency trees, excluding modules retained by another importer. + +Set `PERRY_COLLECT_ONLY=1` to stop after collection and write `/audit.json` +and `module-graph.json` (paths, eager/deferred initialization, and pruned count) +without generating objects or linking an executable. This mode bypasses the +finished-build cache and runs normal collection/preflight checks. For example: + +```sh +PERRY_COLLECT_ONLY=1 perry compile src/index.ts --cache-dir /tmp/graph-after +PERRY_COLLECT_ONLY=1 PERRY_NO_REEXPORT_PRUNE=1 perry compile src/index.ts --cache-dir /tmp/graph-before +``` From d2965409bc783d38d5f210a8ef08e60cdf0fb34e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 14 Sep 2026 01:52:04 +0200 Subject: [PATCH 2/5] compile: retain forwarding imports with loader attributes --- .../collect_modules/reexport_prune/forwarding.rs | 5 +++-- .../issue_10180.rs | 16 ++++++++++++++++ docs/src/cli/flags.md | 1 + 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/perry/src/commands/compile/collect_modules/reexport_prune/forwarding.rs b/crates/perry/src/commands/compile/collect_modules/reexport_prune/forwarding.rs index f6c02b93c4..ec6cf92bab 100644 --- a/crates/perry/src/commands/compile/collect_modules/reexport_prune/forwarding.rs +++ b/crates/perry/src/commands/compile/collect_modules/reexport_prune/forwarding.rs @@ -30,12 +30,13 @@ pub(crate) fn normalize( { return None; } - // Ordinary imports apply packageAliases before resolving; re-export - // declarations currently do not. Do not normalize across that distinction. + // Import aliases/attributes can change resolution or select a file loader. + // Re-export collection does not share those paths; retain such imports. if module.body.iter().any(|item| { matches!(item, ast::ModuleItem::ModuleDecl(ast::ModuleDecl::Import(import)) if import.phase != ast::ImportPhase::Evaluation + || import.with.is_some() || ctx.package_aliases.contains_key(import.src.value.to_string_lossy().as_ref()) ) }) { diff --git a/crates/perry/tests/source_graph_export_regressions/issue_10180.rs b/crates/perry/tests/source_graph_export_regressions/issue_10180.rs index ec46ddb5ae..60ddebf1bd 100644 --- a/crates/perry/tests/source_graph_export_regressions/issue_10180.rs +++ b/crates/perry/tests/source_graph_export_regressions/issue_10180.rs @@ -428,6 +428,22 @@ fn imports_used_by_module_code_are_not_normalized() { assert!(contains(&paths, "/fixture/unused.js")); } +#[test] +fn import_attributes_keep_forwarding_imports_and_their_loader() { + let dir = fixture(Some(false.into())); + write( + dir.path(), + "node_modules/fixture/payload.js", + "export default 99;", + ); + write(dir.path(), "node_modules/fixture/index.js", "import { default as asset } from './payload.js' with { type: 'file' }; import { used } from './used.js'; import { unused } from './unused.js'; export { asset, used, unused };"); + write(dir.path(), "main.ts", "import { asset, used } from 'fixture'; console.log(used, typeof asset, asset.endsWith('payload.js'));"); + let (paths, output) = compile(dir.path(), false, false); + assert_eq!(output, "42 string true\n"); + assert!(contains(&paths, "/fixture/unused.js")); + assert_eq!(output, compile(dir.path(), true, false).1); +} + #[test] fn forwarding_barrels_keep_effectful_dependencies_and_bare_imports() { let dir = fixture(Some(false.into())); diff --git a/docs/src/cli/flags.md b/docs/src/cli/flags.md index 1474291175..f2adce56e0 100644 --- a/docs/src/cli/flags.md +++ b/docs/src/cli/flags.md @@ -630,6 +630,7 @@ dependencies conservatively retain the edge. This pass does not remove direct imports used by module code or individual declarations. An `import { x }; export { x }` pair is first normalized to a re-export only in barrels containing imports and export lists, whose entire static dependency tree has side-effect-free contracts. +Imports with attributes, nonstandard phases, or package aliases retain their original resolution. Namespace and dynamic imports retain the complete exported surface, and existing dynamic initialization stays deferred. From bfacdfb66b53ee6fe83479a17910b63e2759464c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 14 Sep 2026 03:07:12 +0200 Subject: [PATCH 3/5] compile: preserve cyclic initialization when pruning re-exports --- changelog.d/10180-prune-unused-reexports.md | 2 +- .../reexport_prune/forwarding.rs | 29 +++++++++-- .../collect_modules/reexport_prune/scan.rs | 25 ++++++++-- .../issue_10180.rs | 50 ++++++++++++++++++- docs/src/cli/flags.md | 12 +++-- 5 files changed, 103 insertions(+), 15 deletions(-) diff --git a/changelog.d/10180-prune-unused-reexports.md b/changelog.d/10180-prune-unused-reexports.md index e7bc4b539e..78b14254e6 100644 --- a/changelog.d/10180-prune-unused-reexports.md +++ b/changelog.d/10180-prune-unused-reexports.md @@ -1 +1 @@ -Perry now postpones unused named and wildcard re-exports during module collection when package sideEffects declarations prove their static dependency trees safe to omit. Pure import/export-list barrels are normalized to equivalent re-exports. Export demand grows to a fixed point across later importers and barrel chains; imports used by module code, dynamic-import namespaces, live bindings, and effectful or uncertain dependencies remain available. The compile summary reports omitted modules. Set PERRY_NO_REEXPORT_PRUNE=1 for an unpruned comparison, or PERRY_COLLECT_ONLY=1 to write the collection audit without generating code. +Perry now postpones unused named and wildcard re-exports during module collection when package sideEffects declarations prove their static dependency trees safe to omit. Barrels consisting of forwarded named imports are normalized to equivalent re-exports with dependency order preserved. Cyclic dependency trees stay intact so pruning cannot change their initialization entry point. Export demand grows to a fixed point across later importers and barrel chains; imports used by module code, dynamic-import namespaces, live bindings, and effectful or uncertain dependencies remain available. The compile summary reports omitted modules. Set PERRY_NO_REEXPORT_PRUNE=1 for an unpruned comparison, or PERRY_COLLECT_ONLY=1 to write the collection audit without generating code. diff --git a/crates/perry/src/commands/compile/collect_modules/reexport_prune/forwarding.rs b/crates/perry/src/commands/compile/collect_modules/reexport_prune/forwarding.rs index ec6cf92bab..ccad9a82f0 100644 --- a/crates/perry/src/commands/compile/collect_modules/reexport_prune/forwarding.rs +++ b/crates/perry/src/commands/compile/collect_modules/reexport_prune/forwarding.rs @@ -1,8 +1,8 @@ //! Published barrels (notably Remeda) spell re-exports as imports followed by -//! `export { local as public }`. Normalize only modules containing imports, -//! export lists and empty statements, with an entirely side-effect-free static -//! dependency tree. No imported binding can then be used by module code, and -//! reordering import/re-export groups cannot move an effectful dependency. +//! `export { local as public }`. Normalize only modules whose runtime imports +//! are named bindings forwarded through local export lists, with an entirely +//! side-effect-free static dependency tree. Converting the complete import +//! group preserves dependency order even when pure initializers form a cycle. use std::collections::{HashMap, HashSet}; use std::path::Path; @@ -96,6 +96,27 @@ pub(crate) fn normalize( return None; } + // Collection visits imports before re-exports. Moving only part of that + // group (or mixing it with existing re-exports) can reverse the entry into + // a cycle: even sideEffects:false modules can read each other's exported + // `var` initializers. Require the entire runtime group to move together. + if module.body.iter().any(|item| match item { + ast::ModuleItem::ModuleDecl(ast::ModuleDecl::Import(import)) if !import.type_only => { + import.specifiers.is_empty() + || import.specifiers.iter().any(|spec| match spec { + ast::ImportSpecifier::Named(named) => { + !named.is_type_only && !candidates.contains(named.local.sym.as_ref()) + } + _ => true, + }) + } + ast::ModuleItem::ModuleDecl(ast::ModuleDecl::ExportNamed(export)) => export.src.is_some(), + ast::ModuleItem::ModuleDecl(ast::ModuleDecl::ExportAll(_)) => true, + _ => false, + }) { + return None; + } + let mut state = std::mem::take(&mut ctx.reexport_pruner); let safe = state.scan.can_drop_tree(path, ctx); ctx.reexport_pruner = state; diff --git a/crates/perry/src/commands/compile/collect_modules/reexport_prune/scan.rs b/crates/perry/src/commands/compile/collect_modules/reexport_prune/scan.rs index 0acdb7ecf3..a24c61736c 100644 --- a/crates/perry/src/commands/compile/collect_modules/reexport_prune/scan.rs +++ b/crates/perry/src/commands/compile/collect_modules/reexport_prune/scan.rs @@ -222,10 +222,16 @@ impl Scanner { return *result; } let mut seen = HashSet::new(); - let mut work = vec![path.to_owned()]; - while let Some(path) = work.pop() { + let mut visiting = HashSet::new(); + let mut work = vec![(path.to_owned(), false)]; + while let Some((path, finished)) = work.pop() { let canonical = path.canonicalize().unwrap_or_else(|_| path.clone()); - if !seen.insert(canonical.clone()) { + if finished { + visiting.remove(&canonical); + seen.insert(canonical); + continue; + } + if seen.contains(&canonical) { continue; } match self.droppable.get(&canonical) { @@ -236,6 +242,16 @@ impl Scanner { } None => {} } + if !visiting.insert(canonical.clone()) { + // Dropping a barrel edge can change the entry into a retained + // cycle and thus the values of exported `var` initializers. + // Package contracts permit omission, not cyclic reordering. + for ancestor in visiting { + self.droppable.insert(ancestor, false); + } + self.droppable.insert(root, false); + return false; + } if !self.declared_pure(&canonical) { self.droppable.insert(root, false); return false; @@ -245,7 +261,8 @@ impl Scanner { self.droppable.insert(root, false); return false; } - work.extend(summary.dependencies); + work.push((path, true)); + work.extend(summary.dependencies.into_iter().map(|path| (path, false))); } // Only cache success for the whole explored set after checking all // branches. Caching a partially visited cycle could hide an effect. diff --git a/crates/perry/tests/source_graph_export_regressions/issue_10180.rs b/crates/perry/tests/source_graph_export_regressions/issue_10180.rs index 60ddebf1bd..45bd5a088f 100644 --- a/crates/perry/tests/source_graph_export_regressions/issue_10180.rs +++ b/crates/perry/tests/source_graph_export_regressions/issue_10180.rs @@ -287,7 +287,7 @@ fn collect_only_handles_star_cycles_and_unknown_globs_conservatively() { "export * from './index.js';", ); let (paths, _) = compile(dir.path(), false, true); - assert!(!contains(&paths, "/fixture/unused.js")); + assert!(contains(&paths, "/fixture/unused.js")); write( dir.path(), "node_modules/fixture/package.json", @@ -455,7 +455,7 @@ fn forwarding_barrels_keep_effectful_dependencies_and_bare_imports() { ); let (paths, output) = compile(dir.path(), false, false); assert_eq!(output, "42\n"); - assert!(!contains(&paths, "/fixture/unused.js")); + assert!(contains(&paths, "/fixture/unused.js")); assert!(contains(&paths, "/fixture/bare.js")); write( @@ -479,3 +479,49 @@ fn forwarding_barrels_keep_effectful_dependencies_and_bare_imports() { assert!(contains(&paths, "/external/index.js")); assert_eq!(output, compile(dir.path(), true, false).1); } + +#[test] +fn cyclic_initialization_order_survives_forwarding_and_pruning() { + let dir = fixture(Some(false.into())); + write( + dir.path(), + "node_modules/fixture/index.js", + "import { a } from './a.js'; import * as bns from './b.js'; export { a, bns };", + ); + write( + dir.path(), + "node_modules/fixture/a.js", + "import { b } from './b.js'; export var a = (b ?? 0) + 1;", + ); + write( + dir.path(), + "node_modules/fixture/b.js", + "import { a } from './a.js'; export var b = (a ?? 0) + 1;", + ); + write( + dir.path(), + "main.ts", + "import { a, bns } from 'fixture'; console.log(a, bns.b);", + ); + let (paths, output) = compile(dir.path(), false, false); + assert!(contains(&paths, "/fixture/a.js")); + assert!(contains(&paths, "/fixture/b.js")); + assert_eq!(output, "2 1\n"); + assert_eq!(output, compile(dir.path(), true, false).1); + + write( + dir.path(), + "node_modules/fixture/index.js", + "export { a } from './a.js'; export { b } from './b.js';", + ); + write( + dir.path(), + "main.ts", + "import { b } from 'fixture'; console.log(b);", + ); + let (paths, output) = compile(dir.path(), false, false); + assert!(contains(&paths, "/fixture/a.js")); + assert!(contains(&paths, "/fixture/b.js")); + assert_eq!(output, "1\n"); + assert_eq!(output, compile(dir.path(), true, false).1); +} diff --git a/docs/src/cli/flags.md b/docs/src/cli/flags.md index f2adce56e0..8e8b915f98 100644 --- a/docs/src/cli/flags.md +++ b/docs/src/cli/flags.md @@ -625,11 +625,15 @@ Collection prunes unused `export { name } from`, `export * from`, and namespace re-export edges by default when the exporting package declares the file free of side effects and every static dependency of the omitted target has the same guarantee. Perry honors `sideEffects: false` and arrays of `*`, `**`, and `?` -globs. Unsupported patterns, missing contracts, CommonJS, and unresolved -dependencies conservatively retain the edge. This pass does not remove direct +globs. Unsupported patterns, missing contracts, CommonJS, unresolved dependencies, +and cyclic static dependency trees conservatively retain the edge. Keeping cycles +preserves initialization order even when exported variables read each other. +This pass does not remove direct imports used by module code or individual declarations. An `import { x }; export { x }` -pair is first normalized to a re-export only in barrels containing imports and -export lists, whose entire static dependency tree has side-effect-free contracts. +pair is first normalized to a re-export only in barrels whose runtime imports +are all named bindings forwarded through local export lists, and whose entire +static dependency tree has side-effect-free contracts. Moving the complete +import group preserves dependency order in cycles. Mixed barrels retain their imports. Imports with attributes, nonstandard phases, or package aliases retain their original resolution. Namespace and dynamic imports retain the complete exported surface, and existing dynamic initialization stays deferred. From 432a2bcb3c881ce02c8d846cb4f6d99c777024a4 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 14 Sep 2026 06:16:29 +0200 Subject: [PATCH 4/5] perf(compile): prune pure and mixed forwarding barrels --- changelog.d/10180-prune-unused-reexports.md | 2 +- .../compile/collect_modules/reexport_prune.rs | 8 +- .../reexport_prune/forwarding.rs | 31 ++- .../collect_modules/reexport_prune/purity.rs | 139 +++++++++++ .../collect_modules/reexport_prune/scan.rs | 14 +- .../reexport_prune/side_effects.rs | 87 +++++-- .../issue_10180.rs | 5 +- .../issue_10180/purity.rs | 226 ++++++++++++++++++ docs/src/cli/flags.md | 19 +- 9 files changed, 486 insertions(+), 45 deletions(-) create mode 100644 crates/perry/src/commands/compile/collect_modules/reexport_prune/purity.rs create mode 100644 crates/perry/tests/source_graph_export_regressions/issue_10180/purity.rs diff --git a/changelog.d/10180-prune-unused-reexports.md b/changelog.d/10180-prune-unused-reexports.md index 78b14254e6..b204364c47 100644 --- a/changelog.d/10180-prune-unused-reexports.md +++ b/changelog.d/10180-prune-unused-reexports.md @@ -1 +1 @@ -Perry now postpones unused named and wildcard re-exports during module collection when package sideEffects declarations prove their static dependency trees safe to omit. Barrels consisting of forwarded named imports are normalized to equivalent re-exports with dependency order preserved. Cyclic dependency trees stay intact so pruning cannot change their initialization entry point. Export demand grows to a fixed point across later importers and barrel chains; imports used by module code, dynamic-import namespaces, live bindings, and effectful or uncertain dependencies remain available. The compile summary reports omitted modules. Set PERRY_NO_REEXPORT_PRUNE=1 for an unpruned comparison, or PERRY_COLLECT_ONLY=1 to write the collection audit without generating code. +Perry now postpones unused named and wildcard re-exports during module collection when package sideEffects declarations or conservative analysis of inert initialization prove their static dependency trees safe to omit. Analysis also covers first-party modules and packages without metadata; explicit effectful contracts remain authoritative. Barrels consisting of forwarded named imports are normalized to equivalent re-exports with dependency order preserved. Cyclic dependency trees stay intact so pruning cannot change their initialization entry point. Export demand grows to a fixed point across later importers and barrel chains; imports used by module code, dynamic-import namespaces, live bindings, and effectful or uncertain dependencies remain available. The compile summary reports omitted modules. Set PERRY_NO_REEXPORT_PRUNE=1 for an unpruned comparison, or PERRY_COLLECT_ONLY=1 to write the collection audit without generating code. diff --git a/crates/perry/src/commands/compile/collect_modules/reexport_prune.rs b/crates/perry/src/commands/compile/collect_modules/reexport_prune.rs index ec729cb5cd..28e2e02594 100644 --- a/crates/perry/src/commands/compile/collect_modules/reexport_prune.rs +++ b/crates/perry/src/commands/compile/collect_modules/reexport_prune.rs @@ -1,7 +1,8 @@ //! Collection-time, monotone export demand. Ordinary imports are retained. An //! unused re-export is postponed only when its entire static dependency tree -//! is covered by package sideEffects contracts. Later importers can reactivate -//! it; HIR edges are removed only after every collection root has settled. +//! is covered by package sideEffects contracts or inert-initialization proofs. +//! Later importers can reactivate it; HIR edges are removed only after every +//! collection root has settled. use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -11,6 +12,7 @@ use perry_hir::{Export, Import, ImportSpecifier}; use super::super::CompilationContext; mod forwarding; +mod purity; mod scan; mod side_effects; pub(super) use forwarding::normalize as normalize_forwarding_barrel; @@ -170,7 +172,7 @@ pub(super) fn record( return true; } let mut state = std::mem::take(&mut ctx.reexport_pruner); - let safe = state.scan.declared_pure(from) && state.scan.can_drop_tree(source_path, ctx); + let safe = state.scan.module_is_pure(from, ctx) && state.scan.can_drop_tree(source_path, ctx); let mut edge = Edge { from: from.to_owned(), index, diff --git a/crates/perry/src/commands/compile/collect_modules/reexport_prune/forwarding.rs b/crates/perry/src/commands/compile/collect_modules/reexport_prune/forwarding.rs index ccad9a82f0..a875eaa82c 100644 --- a/crates/perry/src/commands/compile/collect_modules/reexport_prune/forwarding.rs +++ b/crates/perry/src/commands/compile/collect_modules/reexport_prune/forwarding.rs @@ -1,8 +1,9 @@ //! Published barrels (notably Remeda) spell re-exports as imports followed by //! `export { local as public }`. Normalize only modules whose runtime imports -//! are named bindings forwarded through local export lists, with an entirely -//! side-effect-free static dependency tree. Converting the complete import -//! group preserves dependency order even when pure initializers form a cycle. +//! are bare imports or named bindings forwarded through local export lists, +//! with an entirely side-effect-free, acyclic static dependency tree. Bare +//! imports remain imports; the cycle proof permits moving forwarded bindings +//! after them without changing the initialization of exported values. use std::collections::{HashMap, HashSet}; use std::path::Path; @@ -96,22 +97,20 @@ pub(crate) fn normalize( return None; } - // Collection visits imports before re-exports. Moving only part of that - // group (or mixing it with existing re-exports) can reverse the entry into - // a cycle: even sideEffects:false modules can read each other's exported - // `var` initializers. Require the entire runtime group to move together. + // Keep namespaces and bindings with uses other than forwarding. Genuine + // bare imports can stay in place: can_drop_tree below proves the complete + // graph free of effects AND cycles before any dependency order changes. + // This covers generated barrels that repeat their shared helper chunks + // as bare imports, as well as mixed direct/indirect re-exports. if module.body.iter().any(|item| match item { ast::ModuleItem::ModuleDecl(ast::ModuleDecl::Import(import)) if !import.type_only => { - import.specifiers.is_empty() - || import.specifiers.iter().any(|spec| match spec { - ast::ImportSpecifier::Named(named) => { - !named.is_type_only && !candidates.contains(named.local.sym.as_ref()) - } - _ => true, - }) + import.specifiers.iter().any(|spec| match spec { + ast::ImportSpecifier::Named(named) => { + !named.is_type_only && !candidates.contains(named.local.sym.as_ref()) + } + _ => true, + }) } - ast::ModuleItem::ModuleDecl(ast::ModuleDecl::ExportNamed(export)) => export.src.is_some(), - ast::ModuleItem::ModuleDecl(ast::ModuleDecl::ExportAll(_)) => true, _ => false, }) { return None; diff --git a/crates/perry/src/commands/compile/collect_modules/reexport_prune/purity.rs b/crates/perry/src/commands/compile/collect_modules/reexport_prune/purity.rs new file mode 100644 index 0000000000..31bba094fe --- /dev/null +++ b/crates/perry/src/commands/compile/collect_modules/reexport_prune/purity.rs @@ -0,0 +1,139 @@ +//! Deliberately small proof for modules without a package sideEffects contract. +//! Only expressions whose evaluation cannot call user code, read a binding, or +//! throw are accepted. Dependencies are checked separately by the scanner. + +use swc_ecma_ast as ast; + +pub(super) fn module_is_pure(module: &ast::Module) -> bool { + module.body.iter().all(|item| match item { + ast::ModuleItem::Stmt(ast::Stmt::Empty(_)) => true, + ast::ModuleItem::Stmt(ast::Stmt::Expr(stmt)) => expr_is_pure(&stmt.expr), + ast::ModuleItem::Stmt(ast::Stmt::Decl(decl)) => decl_is_pure(decl), + ast::ModuleItem::ModuleDecl(decl) => match decl { + ast::ModuleDecl::Import(import) => { + import.type_only + || (import.phase == ast::ImportPhase::Evaluation && import.with.is_none()) + } + ast::ModuleDecl::ExportNamed(export) => export.with.is_none(), + ast::ModuleDecl::ExportAll(export) => export.with.is_none(), + ast::ModuleDecl::ExportDecl(export) => decl_is_pure(&export.decl), + ast::ModuleDecl::ExportDefaultDecl(export) => match &export.decl { + ast::DefaultDecl::Fn(function) => function_is_pure(&function.function), + ast::DefaultDecl::TsInterfaceDecl(_) => true, + _ => false, + }, + ast::ModuleDecl::ExportDefaultExpr(export) => expr_is_pure(&export.expr), + _ => false, + }, + _ => false, + }) +} + +fn decl_is_pure(decl: &ast::Decl) -> bool { + match decl { + ast::Decl::Fn(function) => function_is_pure(&function.function), + ast::Decl::Var(var) => { + var.declare + || var.decls.iter().all(|decl| { + // Destructuring can invoke iterators/getters even when the + // initializer is a freshly created object or array. + matches!(decl.name, ast::Pat::Ident(_)) + && decl.init.as_deref().is_none_or(expr_is_pure) + }) + } + ast::Decl::TsInterface(_) | ast::Decl::TsTypeAlias(_) => true, + _ => false, + } +} + +fn function_is_pure(function: &ast::Function) -> bool { + // Parameters and the body execute on invocation; decorators execute when + // the enclosing module initializes. Do not inspect deferred function code. + function.decorators.is_empty() + && function + .params + .iter() + .all(|param| param.decorators.is_empty()) +} + +fn expr_is_pure(expr: &ast::Expr) -> bool { + match expr { + ast::Expr::Lit(lit) => !matches!(lit, ast::Lit::JSXText(_)), + ast::Expr::Fn(function) => function_is_pure(&function.function), + ast::Expr::Arrow(_) => true, + ast::Expr::Paren(paren) => expr_is_pure(&paren.expr), + ast::Expr::TsAs(expr) => expr_is_pure(&expr.expr), + ast::Expr::TsSatisfies(expr) => expr_is_pure(&expr.expr), + ast::Expr::TsTypeAssertion(expr) => expr_is_pure(&expr.expr), + ast::Expr::TsConstAssertion(expr) => expr_is_pure(&expr.expr), + ast::Expr::TsNonNull(expr) => expr_is_pure(&expr.expr), + ast::Expr::Array(array) => array + .elems + .iter() + .flatten() + .all(|element| element.spread.is_none() && expr_is_pure(&element.expr)), + ast::Expr::Object(object) => object.props.iter().all(|property| { + let ast::PropOrSpread::Prop(property) = property else { + return false; + }; + match property.as_ref() { + ast::Prop::KeyValue(property) => { + !matches!(property.key, ast::PropName::Computed(_)) + && expr_is_pure(&property.value) + } + _ => false, + } + }), + // Even an identifier read can throw (TDZ/unbound), property reads can + // invoke getters, and arithmetic can coerce objects or mix BigInts. + // Classes can evaluate extends, computed keys, decorators and statics. + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_only_inert_initialization_and_deferred_function_bodies() { + for source in [ + "export { answer } from './answer.js'; export * from './unused.js';", + "import './dependency.js'; export const unused = 99;", + "'use strict'; export const values = [1, null, { ok: true }];", + "export function unused(x = effect()) { return effect(x); }", + "export const unused = async () => await import('./lazy.js');", + "export default function () { throw new Error('called'); }", + "export const unused = ({ value: 42 } as const);", + "export interface Shape { x: number }; export type Alias = string;", + ] { + let module = perry_parser::parse_typescript(source, "fixture.ts").unwrap(); + assert!(module_is_pure(&module), "{source}"); + } + } + + #[test] + fn preserves_observable_or_unknown_initialization() { + for source in [ + "console.log('effect'); export const unused = 99;", + "export const unused = effect();", + "export const unused = missing;", + "export const unused = object.getter;", + "export const unused = 1n + 1;", + "export const unused = { [key]: 1 };", + "export const unused = { ...object };", + "export const unused = [...array];", + "export const { unused } = { get unused() { effect(); } };", + "export class Unused { static value = effect(); }", + "export default class extends effect() {}", + "export const unused = import('./lazy.js');", + "await effect(); export const unused = 99;", + "export enum Unused { Value = effect() }", + "using unused = resource();", + "import value from './asset.js' with { type: 'file' };", + ] { + let module = perry_parser::parse_typescript(source, "fixture.ts").unwrap(); + assert!(!module_is_pure(&module), "{source}"); + } + } +} diff --git a/crates/perry/src/commands/compile/collect_modules/reexport_prune/scan.rs b/crates/perry/src/commands/compile/collect_modules/reexport_prune/scan.rs index a24c61736c..2f9c4d5db0 100644 --- a/crates/perry/src/commands/compile/collect_modules/reexport_prune/scan.rs +++ b/crates/perry/src/commands/compile/collect_modules/reexport_prune/scan.rs @@ -20,6 +20,7 @@ struct Summary { dependencies: Vec, unknown_exports: bool, unknown_dependencies: bool, + pure: bool, } #[derive(Default)] @@ -31,8 +32,10 @@ pub(super) struct Scanner { } impl Scanner { - pub(super) fn declared_pure(&mut self, path: &Path) -> bool { - self.contracts.is_pure(path) + pub(super) fn module_is_pure(&mut self, path: &Path, ctx: &mut CompilationContext) -> bool { + self.contracts + .is_pure(path) + .unwrap_or_else(|| self.summary(path, ctx).pure) } fn summary(&mut self, path: &Path, ctx: &mut CompilationContext) -> Summary { @@ -54,6 +57,7 @@ impl Scanner { if let Some(module) = parsed { let defined = ctx.parsed_defines.apply(&module); let module = defined.as_ref().unwrap_or(&module); + result.pure = super::purity::module_is_pure(module); let mut opaque = OpaqueLoads::default(); module.visit_with(&mut opaque); result.unknown_dependencies = opaque.0; @@ -65,6 +69,8 @@ impl Scanner { let mut star = false; match decl { ast::ModuleDecl::Import(import) if !import.type_only => { + result.unknown_dependencies |= + import.with.is_some() || import.phase != ast::ImportPhase::Evaluation; if import.specifiers.is_empty() || import.specifiers.iter().any( |s| !matches!(s, ast::ImportSpecifier::Named(n) if n.is_type_only), @@ -74,10 +80,12 @@ impl Scanner { } } ast::ModuleDecl::ExportAll(export) if !export.type_only => { + result.unknown_dependencies |= export.with.is_some(); dependency = Some(&export.src); star = true; } ast::ModuleDecl::ExportNamed(export) if !export.type_only => { + result.unknown_dependencies |= export.with.is_some(); let mut runtime = export.specifiers.is_empty(); for spec in &export.specifiers { let name = match spec { @@ -252,7 +260,7 @@ impl Scanner { self.droppable.insert(root, false); return false; } - if !self.declared_pure(&canonical) { + if !self.module_is_pure(&canonical, ctx) { self.droppable.insert(root, false); return false; } diff --git a/crates/perry/src/commands/compile/collect_modules/reexport_prune/side_effects.rs b/crates/perry/src/commands/compile/collect_modules/reexport_prune/side_effects.rs index b7a1a463e0..ba3b75cd52 100644 --- a/crates/perry/src/commands/compile/collect_modules/reexport_prune/side_effects.rs +++ b/crates/perry/src/commands/compile/collect_modules/reexport_prune/side_effects.rs @@ -3,12 +3,14 @@ use std::path::{Path, PathBuf}; #[derive(Default)] pub(super) struct Contracts { - files: HashMap, + files: HashMap>, manifests: HashMap>, } impl Contracts { - pub(super) fn is_pure(&mut self, path: &Path) -> bool { + /// None permits AST inference. An explicit effectful/unknown contract + /// vetoes omission even when the current source looks inert. + pub(super) fn is_pure(&mut self, path: &Path) -> Option { if let Some(pure) = self.files.get(path) { return *pure; } @@ -17,7 +19,7 @@ impl Contracts { pure } - fn lookup(&mut self, path: &Path) -> bool { + fn lookup(&mut self, path: &Path) -> Option { // Stop at the owning package, never inherit a parent's contract across // nested node_modules. Type-only package.json files inside dist/ may // omit sideEffects; the owning package's declaration still applies. @@ -28,21 +30,18 @@ impl Contracts { prefix.push(component); if component.as_os_str() == "node_modules" { let Some(name) = components.next() else { - return false; + return Some(false); }; prefix.push(name); if name.as_os_str().to_string_lossy().starts_with('@') { let Some(name) = components.next() else { - return false; + return Some(false); }; prefix.push(name); } package_root = Some(prefix.clone()); } } - let Some(root) = package_root else { - return false; - }; for dir in path.parent().into_iter().flat_map(Path::ancestors) { let manifest = self.manifests.entry(dir.to_owned()).or_insert_with(|| { std::fs::read(dir.join("package.json")) @@ -50,14 +49,14 @@ impl Contracts { .and_then(|bytes| serde_json::from_slice(&bytes).ok()) }); if manifest.is_none() && dir.join("package.json").exists() { - return false; + return Some(false); } if let Some(value) = manifest.as_ref().and_then(|v| v.get("sideEffects")) { - return match value { + return Some(match value { serde_json::Value::Bool(false) => true, serde_json::Value::Array(patterns) => { let Ok(relative) = path.strip_prefix(dir) else { - return false; + return Some(false); }; let relative = relative.to_string_lossy().replace('\\', "/"); patterns.iter().all(|pattern| { @@ -67,13 +66,25 @@ impl Contracts { }) } _ => false, - }; + }); } - if dir == root { + // Canonical workspace-package paths may live outside node_modules. + // Type-only dist manifests do not hide their owning contract. + let boundary = package_root.as_deref().map_or_else( + || { + manifest.as_ref().is_some_and(|value| { + !value + .as_object() + .is_some_and(|object| object.len() == 1 && object.contains_key("type")) + }) + }, + |root| dir == root, + ); + if boundary { break; } } - false + None } } @@ -145,6 +156,54 @@ fn segment(pattern: &[u8], text: &[u8]) -> bool { mod tests { use super::*; + #[test] + fn missing_contracts_allow_inference_but_explicit_contracts_override_it() { + let dir = tempfile::tempdir().unwrap(); + let package = dir.path().join("node_modules/fixture"); + std::fs::create_dir_all(package.join("dist")).unwrap(); + let module = package.join("dist/index.js"); + for (manifest, expected) in [ + (r#"{"name":"fixture"}"#, None), + (r#"{"sideEffects":false}"#, Some(true)), + (r#"{"sideEffects":true}"#, Some(false)), + (r#"{"sideEffects":["**/*.js"]}"#, Some(false)), + (r#"{"sideEffects":["**/*.css"]}"#, Some(true)), + (r#"{"sideEffects":["[ab].js"]}"#, Some(false)), + ("invalid json", Some(false)), + ] { + std::fs::write(package.join("package.json"), manifest).unwrap(); + assert_eq!( + Contracts::default().is_pure(&module), + expected, + "{manifest}" + ); + } + } + + #[test] + fn contracts_respect_workspace_and_nested_package_boundaries() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("package.json"), r#"{"sideEffects":false}"#).unwrap(); + let nested = dir.path().join("node_modules/fixture/dist"); + std::fs::create_dir_all(&nested).unwrap(); + let mut contracts = Contracts::default(); + assert_eq!(contracts.is_pure(&dir.path().join("source.js")), Some(true)); + assert_eq!(contracts.is_pure(&nested.join("index.js")), None); + std::fs::write(nested.join("package.json"), r#"{"sideEffects":true}"#).unwrap(); + assert_eq!( + Contracts::default().is_pure(&nested.join("index.js")), + Some(false) + ); + + let dist = dir.path().join("dist/esm"); + std::fs::create_dir_all(&dist).unwrap(); + std::fs::write(dist.join("package.json"), r#"{"type":"module"}"#).unwrap(); + assert_eq!( + Contracts::default().is_pure(&dist.join("index.js")), + Some(true) + ); + } + #[test] fn glob_contracts_fail_closed() { for (pattern, path) in [ diff --git a/crates/perry/tests/source_graph_export_regressions/issue_10180.rs b/crates/perry/tests/source_graph_export_regressions/issue_10180.rs index 45bd5a088f..08b755dbf8 100644 --- a/crates/perry/tests/source_graph_export_regressions/issue_10180.rs +++ b/crates/perry/tests/source_graph_export_regressions/issue_10180.rs @@ -7,6 +7,9 @@ use std::process::Command; use super::{perry_bin, runtime_dir}; +#[path = "issue_10180/purity.rs"] +mod purity; + fn write(root: &Path, path: &str, text: &str) { let path = root.join(path); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); @@ -455,7 +458,7 @@ fn forwarding_barrels_keep_effectful_dependencies_and_bare_imports() { ); let (paths, output) = compile(dir.path(), false, false); assert_eq!(output, "42\n"); - assert!(contains(&paths, "/fixture/unused.js")); + assert!(!contains(&paths, "/fixture/unused.js")); assert!(contains(&paths, "/fixture/bare.js")); write( diff --git a/crates/perry/tests/source_graph_export_regressions/issue_10180/purity.rs b/crates/perry/tests/source_graph_export_regressions/issue_10180/purity.rs new file mode 100644 index 0000000000..820ba6a82e --- /dev/null +++ b/crates/perry/tests/source_graph_export_regressions/issue_10180/purity.rs @@ -0,0 +1,226 @@ +use super::{compile, contains, fixture, perry_bin, runtime_dir, write}; +use std::path::Path; +use std::process::Command; + +fn node_output(root: &Path) -> String { + let output = Command::new("node") + .current_dir(root) + .args(["--experimental-strip-types", "main.ts"]) + .output() + .unwrap(); + assert!(output.status.success(), "{:?}", output); + String::from_utf8(output.stdout) + .unwrap() + .replace("\r\n", "\n") +} + +#[test] +fn inferred_pure_siblings_are_not_compiled_without_package_metadata() { + let dir = fixture(None); + write(dir.path(), "node_modules/fixture/unused.js", "export function unused() { console.log('only when called'); } export const data = [1, { value: 2 }];"); + let (on, output) = compile(dir.path(), false, false); + let (off, baseline) = compile(dir.path(), true, false); + assert_eq!(output, node_output(dir.path())); + assert_eq!(output, baseline); + assert!(!contains(&on, "/fixture/unused.js")); + assert!(contains(&off, "/fixture/unused.js")); + assert_eq!(off.len(), on.len() + 1); +} + +#[test] +fn inferred_purity_applies_to_first_party_forwarding_barrels() { + let dir = fixture(None); + write( + dir.path(), + "main.ts", + "import { used } from './barrel.js'; console.log(used);", + ); + write(dir.path(), "barrel.js", "import { used } from './used.js'; import { unused } from './unused.js'; export { used, unused };"); + write(dir.path(), "used.js", "export const used = 42;"); + write(dir.path(), "unused.js", "export const unused = 99;"); + let (on, output) = compile(dir.path(), false, false); + assert!(!contains(&on, "/unused.js")); + assert_eq!(output, node_output(dir.path())); + assert_eq!(output, compile(dir.path(), true, false).1); +} + +#[test] +fn mixed_forwarding_barrels_prune_unused_siblings_and_keep_bare_dependencies() { + for contract in [None, Some(false.into())] { + let dir = fixture(contract); + write(dir.path(), "node_modules/fixture/index.js", "import { used } from './used.js'; import { unused } from './unused.js'; import './shared.js'; export { used, unused }; export * from './extra.js';"); + write( + dir.path(), + "node_modules/fixture/shared.js", + "export const shared = 7;", + ); + write( + dir.path(), + "node_modules/fixture/extra.js", + "export const extra = 8;", + ); + let (on, output) = compile(dir.path(), false, false); + assert!(!contains(&on, "/fixture/unused.js")); + assert!(!contains(&on, "/fixture/extra.js")); + assert!(contains(&on, "/fixture/shared.js")); + assert_eq!(output, node_output(dir.path())); + assert_eq!(output, compile(dir.path(), true, false).1); + } +} + +#[test] +fn mixed_forwarding_barrels_preserve_the_entry_into_a_cycle() { + for contract in [None, Some(false.into())] { + let dir = fixture(contract); + write( + dir.path(), + "node_modules/fixture/index.js", + "import { a } from './a.js'; import './b.js'; export { a };", + ); + write( + dir.path(), + "node_modules/fixture/a.js", + "import { b } from './b.js'; export var a = (b ?? 0) + 1;", + ); + write( + dir.path(), + "node_modules/fixture/b.js", + "import { a } from './a.js'; export var b = (a ?? 0) + 1;", + ); + write( + dir.path(), + "main.ts", + "import { a } from 'fixture'; console.log(a);", + ); + let (_, output) = compile(dir.path(), false, false); + assert_eq!(output, "2\n"); + assert_eq!(output, node_output(dir.path())); + assert_eq!(output, compile(dir.path(), true, false).1); + } +} + +#[test] +fn inferred_purity_retains_effectful_sibling_in_esm_order() { + let dir = fixture(None); + write( + dir.path(), + "node_modules/fixture/used.js", + "console.log('used'); export const used = 42;", + ); + write( + dir.path(), + "node_modules/fixture/unused.js", + "console.log('unused'); export const unused = 99;", + ); + write(dir.path(), "node_modules/fixture/index.js", "export { used } from './used.js'; export { unused } from './unused.js'; export * from './inert.js';"); + write( + dir.path(), + "node_modules/fixture/inert.js", + "export const inert = 0;", + ); + let (on, output) = compile(dir.path(), false, false); + assert!(contains(&on, "/fixture/unused.js")); + assert!(!contains(&on, "/fixture/inert.js")); + assert_eq!(output, "used\nunused\n42\n"); + assert_eq!(output, node_output(dir.path())); + assert_eq!(output, compile(dir.path(), true, false).1); +} + +#[test] +fn inferred_purity_checks_dependencies_and_honors_explicit_contracts() { + let dir = fixture(None); + write( + dir.path(), + "node_modules/external/package.json", + r#"{"name":"external","type":"module","main":"index.js"}"#, + ); + write( + dir.path(), + "node_modules/external/index.js", + "console.log('external');", + ); + write( + dir.path(), + "node_modules/fixture/unused.js", + "import 'external'; export const unused = 99;", + ); + let (on, output) = compile(dir.path(), false, false); + assert!(contains(&on, "/fixture/unused.js")); + assert!(contains(&on, "/external/index.js")); + assert_eq!(output, "external\n42\n"); + assert_eq!(output, node_output(dir.path())); + assert_eq!(output, compile(dir.path(), true, false).1); + + for contract in [serde_json::json!(true), serde_json::json!(["[ab].js"])] { + let dir = fixture(Some(contract)); + let (on, _) = compile(dir.path(), false, true); + assert!(contains(&on, "/fixture/unused.js")); + } +} + +#[test] +fn inferred_pure_dynamic_target_is_available_but_not_eagerly_initialized() { + let dir = fixture(None); + write( + dir.path(), + "node_modules/fixture/lazy.js", + "console.log('lazy init'); export const lazy = 7;", + ); + write(dir.path(), "main.ts", "import { used } from 'fixture'; export async function load() { return import('fixture/lazy.js'); } console.log(used);"); + let (on, output) = compile(dir.path(), false, false); + assert!(!contains(&on, "/fixture/unused.js")); + assert!(contains(&on, "/fixture/lazy.js")); + assert_eq!(output, "42\n"); + assert_eq!(output, node_output(dir.path())); + assert_eq!(output, compile(dir.path(), true, false).1); + compile(dir.path(), false, true); + let graph: serde_json::Value = serde_json::from_slice( + &std::fs::read(dir.path().join("cache-on/module-graph.json")).unwrap(), + ) + .unwrap(); + let lazy = graph["modules"] + .as_array() + .unwrap() + .iter() + .find(|module| { + module["path"] + .as_str() + .unwrap() + .replace('\\', "/") + .ends_with("/fixture/lazy.js") + }) + .unwrap(); + assert_eq!(lazy["init"], "deferred"); +} + +#[test] +fn cached_build_rechecks_inferred_purity_when_omitted_source_gains_an_effect() { + let dir = fixture(None); + let binary = dir.path().join("cached-inferred"); + let run = || { + let output = Command::new(perry_bin()) + .current_dir(dir.path()) + .args(["compile", "main.ts", "--cache-dir", "cache", "-o"]) + .arg(&binary) + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env("PERRY_RUNTIME_DIR", runtime_dir()) + .env("PERRY_NO_REEXPORT_PRUNE", "0") + .env_remove("PERRY_NO_CACHE") + .env_remove("PERRY_COLLECT_ONLY") + .output() + .unwrap(); + assert!(output.status.success(), "{:?}", output); + let output = Command::new(&binary).output().unwrap(); + assert!(output.status.success(), "{:?}", output); + String::from_utf8(output.stdout).unwrap() + }; + assert_eq!(run(), "42\n"); + assert_eq!(run(), "42\n"); + write( + dir.path(), + "node_modules/fixture/unused.js", + "console.log('restored'); export const unused = 99;", + ); + assert_eq!(run(), "restored\n42\n"); + assert_eq!(run(), node_output(dir.path())); +} diff --git a/docs/src/cli/flags.md b/docs/src/cli/flags.md index 8e8b915f98..0154c8bf04 100644 --- a/docs/src/cli/flags.md +++ b/docs/src/cli/flags.md @@ -622,18 +622,23 @@ every claimed profile selection. ## Unused re-export collection Collection prunes unused `export { name } from`, `export * from`, and namespace -re-export edges by default when the exporting package declares the file free -of side effects and every static dependency of the omitted target has the same -guarantee. Perry honors `sideEffects: false` and arrays of `*`, `**`, and `?` -globs. Unsupported patterns, missing contracts, CommonJS, unresolved dependencies, +re-export edges by default when the exporting file and every static dependency +of the omitted target are proven free of side effects. Perry honors +`sideEffects: false` and arrays of `*`, `**`, and `?` globs. Without a contract, +Perry recognizes inert declarations such as literal constants, functions, and +pure forwarding barrels, including first-party modules. Calls, binding/property +reads, classes, destructuring, and other unproven initialization retain the module. +Explicit effectful contracts and unsupported patterns override this inference. +CommonJS, unresolved dependencies, and cyclic static dependency trees conservatively retain the edge. Keeping cycles preserves initialization order even when exported variables read each other. This pass does not remove direct imports used by module code or individual declarations. An `import { x }; export { x }` pair is first normalized to a re-export only in barrels whose runtime imports -are all named bindings forwarded through local export lists, and whose entire -static dependency tree has side-effect-free contracts. Moving the complete -import group preserves dependency order in cycles. Mixed barrels retain their imports. +are bare imports or named bindings forwarded through local export lists, and +whose entire static dependency tree is proven free of side effects and cycles. +Bare imports remain in the graph. Cyclic barrels retain their original imports +so normalization cannot change their initialization entry point. Imports with attributes, nonstandard phases, or package aliases retain their original resolution. Namespace and dynamic imports retain the complete exported surface, and existing dynamic initialization stays deferred. From 24b530555642cc9cc368b069bd9fe562d4ac3781 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 14 Sep 2026 06:36:48 +0200 Subject: [PATCH 5/5] fix(compile): preserve lexical paths during purity inference --- .../src/commands/compile/collect_modules.rs | 1 + .../compile/collect_modules/reexport_prune.rs | 4 +- .../collect_modules/reexport_prune/scan.rs | 7 +- .../issue_10180/purity.rs | 66 +++++++++++++++++++ 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index b3221d4b08..7eddd0ae44 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -1934,6 +1934,7 @@ fn collect_module_one( if reexport_prune::record( ctx, &canonical, + entry_path, export_index, export, &resolved_path, diff --git a/crates/perry/src/commands/compile/collect_modules/reexport_prune.rs b/crates/perry/src/commands/compile/collect_modules/reexport_prune.rs index 28e2e02594..74460865b2 100644 --- a/crates/perry/src/commands/compile/collect_modules/reexport_prune.rs +++ b/crates/perry/src/commands/compile/collect_modules/reexport_prune.rs @@ -163,6 +163,7 @@ impl ReexportPruner { pub(super) fn record( ctx: &mut CompilationContext, from: &Path, + from_source: &Path, index: usize, export: &Export, target: &Path, @@ -172,7 +173,8 @@ pub(super) fn record( return true; } let mut state = std::mem::take(&mut ctx.reexport_pruner); - let safe = state.scan.module_is_pure(from, ctx) && state.scan.can_drop_tree(source_path, ctx); + let safe = + state.scan.module_is_pure(from_source, ctx) && state.scan.can_drop_tree(source_path, ctx); let mut edge = Edge { from: from.to_owned(), index, diff --git a/crates/perry/src/commands/compile/collect_modules/reexport_prune/scan.rs b/crates/perry/src/commands/compile/collect_modules/reexport_prune/scan.rs index 2f9c4d5db0..d4ea1faea7 100644 --- a/crates/perry/src/commands/compile/collect_modules/reexport_prune/scan.rs +++ b/crates/perry/src/commands/compile/collect_modules/reexport_prune/scan.rs @@ -33,8 +33,9 @@ pub(super) struct Scanner { impl Scanner { pub(super) fn module_is_pure(&mut self, path: &Path, ctx: &mut CompilationContext) -> bool { + let canonical = path.canonicalize().unwrap_or_else(|_| path.to_owned()); self.contracts - .is_pure(path) + .is_pure(&canonical) .unwrap_or_else(|| self.summary(path, ctx).pure) } @@ -260,7 +261,9 @@ impl Scanner { self.droppable.insert(root, false); return false; } - if !self.module_is_pure(&canonical, ctx) { + // Preserve the source-visible base while inference fills the AST + // summary, just as ordinary collection resolves relative imports. + if !self.module_is_pure(&path, ctx) { self.droppable.insert(root, false); return false; } diff --git a/crates/perry/tests/source_graph_export_regressions/issue_10180/purity.rs b/crates/perry/tests/source_graph_export_regressions/issue_10180/purity.rs index 820ba6a82e..bb437d5dc4 100644 --- a/crates/perry/tests/source_graph_export_regressions/issue_10180/purity.rs +++ b/crates/perry/tests/source_graph_export_regressions/issue_10180/purity.rs @@ -44,6 +44,72 @@ fn inferred_purity_applies_to_first_party_forwarding_barrels() { assert_eq!(output, compile(dir.path(), true, false).1); } +#[cfg(any(unix, windows))] +#[test] +fn inferred_purity_resolves_dependencies_from_the_source_visible_path() { + let dir = fixture(None); + write( + dir.path(), + "main.ts", + "import { used } from './visible/barrel/index.js'; console.log(used);", + ); + write( + dir.path(), + "actual/barrel/index.js", + "export { used } from './used.js'; export { unused } from './unused.js';", + ); + write( + dir.path(), + "actual/barrel/used.js", + "export const used = 42;", + ); + write( + dir.path(), + "actual/barrel/unused.js", + "import '../effect.js'; export const unused = 99;", + ); + write(dir.path(), "actual/effect.js", "export const inert = 0;"); + write(dir.path(), "visible/effect.js", "console.log('visible');"); + let actual = dir.path().join("actual/barrel"); + let alias = dir.path().join("visible/barrel"); + #[cfg(unix)] + std::os::unix::fs::symlink(&actual, &alias).unwrap(); + #[cfg(windows)] + { + // Directory junctions need no administrator/developer-mode privilege. + let output = Command::new("cmd") + .args(["/C", "mklink", "/J"]) + .arg(alias.to_string_lossy().replace('/', "\\")) + .arg(actual.to_string_lossy().replace('/', "\\")) + .output() + .unwrap(); + assert!(output.status.success(), "{:?}", output); + } + let (on, output) = compile(dir.path(), false, false); + assert!(contains(&on, "/actual/barrel/unused.js")); + assert!(contains(&on, "/visible/effect.js")); + assert!(!contains(&on, "/actual/effect.js")); + assert_eq!(output, "visible\n42\n"); + assert_eq!(output, compile(dir.path(), true, false).1); + // Perry deliberately uses the lexical import base through symlinks. + let node = Command::new("node") + .current_dir(dir.path()) + .args([ + "--preserve-symlinks", + "--experimental-strip-types", + "main.ts", + ]) + .output() + .unwrap(); + assert!(node.status.success(), "{:?}", node); + assert_eq!( + output, + String::from_utf8(node.stdout) + .unwrap() + .replace("\r\n", "\n") + ); +} + #[test] fn mixed_forwarding_barrels_prune_unused_siblings_and_keep_bare_dependencies() { for contract in [None, Some(false.into())] {