Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/10180-prune-unused-reexports.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions crates/perry/src/commands/compile/bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 => {}
Expand Down
22 changes: 21 additions & 1 deletion crates/perry/src/commands/compile/build_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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::<BTreeSet<_>>();
let recorded_config_paths = manifest
.config_inputs
.iter()
.map(|input| input.path.clone())
.collect::<BTreeSet<_>>();
if config_paths != recorded_config_paths {
return miss("config-paths");
}
if verify_files(&manifest.config_inputs).is_err() {
return miss("config");
}
Expand Down Expand Up @@ -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() {
Expand Down
22 changes: 20 additions & 2 deletions crates/perry/src/commands/compile/collect_modules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down
268 changes: 268 additions & 0 deletions crates/perry/src/commands/compile/collect_modules/reexport_prune.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

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<PathBuf, Demand>,
edges: Vec<Edge>,
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<Demand> {
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<PathBuf> {
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<PathBuf, HashSet<usize>> = 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(())
}
Loading
Loading