diff --git a/changelog.d/10180-prune-unused-reexports.md b/changelog.d/10180-prune-unused-reexports.md new file mode 100644 index 0000000000..b204364c47 --- /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 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/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..7eddd0ae44 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,19 @@ fn collect_module_one( } match kind { - ModuleKind::NativeCompiled => pending.push(source_path), + ModuleKind::NativeCompiled => { + if reexport_prune::record( + ctx, + &canonical, + entry_path, + 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..74460865b2 --- /dev/null +++ b/crates/perry/src/commands/compile/collect_modules/reexport_prune.rs @@ -0,0 +1,268 @@ +//! 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 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}; + +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; + +#[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, + from_source: &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.module_is_pure(from_source, ctx) && 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..a875eaa82c --- /dev/null +++ b/crates/perry/src/commands/compile/collect_modules/reexport_prune/forwarding.rs @@ -0,0 +1,196 @@ +//! Published barrels (notably Remeda) spell re-exports as imports followed by +//! `export { local as public }`. Normalize only modules whose runtime imports +//! 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; + +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; + } + // 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()) + ) + }) { + 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; + } + + // 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.iter().any(|spec| match spec { + ast::ImportSpecifier::Named(named) => { + !named.is_type_only && !candidates.contains(named.local.sym.as_ref()) + } + _ => 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; + 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/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 new file mode 100644 index 0000000000..d4ea1faea7 --- /dev/null +++ b/crates/perry/src/commands/compile/collect_modules/reexport_prune/scan.rs @@ -0,0 +1,352 @@ +//! 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, + pure: bool, +} + +#[derive(Default)] +pub(super) struct Scanner { + summaries: HashMap, + droppable: HashMap, + exports: HashMap<(PathBuf, Option), bool>, + contracts: Contracts, +} + +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(&canonical) + .unwrap_or_else(|| self.summary(path, ctx).pure) + } + + 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); + result.pure = super::purity::module_is_pure(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 => { + 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), + ) + { + dependency = Some(&import.src); + } + } + 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 { + 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 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 finished { + visiting.remove(&canonical); + seen.insert(canonical); + continue; + } + if seen.contains(&canonical) { + continue; + } + match self.droppable.get(&canonical) { + Some(true) => continue, + Some(false) => { + self.droppable.insert(root, false); + return false; + } + 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; + } + // 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; + } + let summary = self.summary(&path, ctx); + if summary.unknown_dependencies { + self.droppable.insert(root, false); + return false; + } + 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. + 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..ba3b75cd52 --- /dev/null +++ b/crates/perry/src/commands/compile/collect_modules/reexport_prune/side_effects.rs @@ -0,0 +1,223 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +#[derive(Default)] +pub(super) struct Contracts { + files: HashMap>, + manifests: HashMap>, +} + +impl Contracts { + /// 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; + } + let pure = self.lookup(path); + self.files.insert(path.to_owned(), pure); + pure + } + + 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. + 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 Some(false); + }; + prefix.push(name); + if name.as_os_str().to_string_lossy().starts_with('@') { + let Some(name) = components.next() else { + return Some(false); + }; + prefix.push(name); + } + package_root = Some(prefix.clone()); + } + } + 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 Some(false); + } + if let Some(value) = manifest.as_ref().and_then(|v| v.get("sideEffects")) { + return Some(match value { + serde_json::Value::Bool(false) => true, + serde_json::Value::Array(patterns) => { + let Ok(relative) = path.strip_prefix(dir) else { + return Some(false); + }; + let relative = relative.to_string_lossy().replace('\\', "/"); + patterns.iter().all(|pattern| { + pattern + .as_str() + .is_some_and(|pattern| !may_match(pattern, &relative)) + }) + } + _ => false, + }); + } + // 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; + } + } + None + } +} + +/// 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 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 [ + ("*.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..08b755dbf8 --- /dev/null +++ b/crates/perry/tests/source_graph_export_regressions/issue_10180.rs @@ -0,0 +1,530 @@ +//! 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}; + +#[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(); + 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 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())); + 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); +} + +#[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/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..bb437d5dc4 --- /dev/null +++ b/crates/perry/tests/source_graph_export_regressions/issue_10180/purity.rs @@ -0,0 +1,292 @@ +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); +} + +#[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())] { + 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 4c4d226773..0154c8bf04 100644 --- a/docs/src/cli/flags.md +++ b/docs/src/cli/flags.md @@ -618,3 +618,41 @@ 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 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 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. + +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 +```