feat(compile): module-graph tree-shaking / DCE for compilePackages (#2309) - #2325
Merged
Merged
Conversation
added 2 commits
May 29, 2026 00:23
…deEffects prune (#2309) Perry compiles every import-reachable module at module granularity and refuses (during lowering) on genuinely-runtime `new Function` and unimplemented APIs — even when the offending module is only reachable via a dead re-export-barrel edge and never runs. A 5-line ink hello-world fails to compile on es-toolkit's `_.template` `new Function`, which is pulled in only because the `es-toolkit/compat` barrel re-exports it; the consumer imports just `throttle`. Stage 1 adds a tree-shaking pass (off by default behind `PERRY_TREE_SHAKE=1` / `perry.experiments.treeShake`) that, after the full graph is collected, computes binding-level reachability from user code and prunes unreachable node_modules modules before codegen: - Deferred refusals (perry-hir/deferral.rs): while lowering a node_modules module under tree-shaking, `new Function` (eval_classifier) and the 5 #463 unimplemented gates record the refusal and fall through instead of hard-erroring. Re-raised after pruning only for modules that survive — so dead code's refusals are dropped, live code's stay fatal and span-precise. - Reachability fixpoint (compile/reachability.rs): monotone Rooted/Bindings lattice. User modules seed Rooted (never pruned). Honors package.json `"sideEffects": false` — a pure re-export barrel reached only via named bindings follows just the edges feeding needed exports and drops bare side-effect imports, so es-toolkit's `template.mjs` becomes unreachable. Every ambiguous shape (namespace import, export*, dynamic import, unresolvable specifier) conservatively roots the whole target. Surviving barrels have dangling edges to pruned modules rewritten away. Validated: real `import { throttle } from "es-toolkit/compat"` prunes 430 unreachable modules (incl. the `new Function` leaf) and compiles+runs; flag-off path is byte-identical to before. 4 fixpoint unit tests cover barrel-drop, namespace-keeps-all, export*-keeps-source, non-barrel-escalate. Also lands Stage 2 config scaffolding (perry.define / DefineValue, unused until the env-fold pass). Refs #2309
…ad-branch elimination (#2309) Removes statically-dead `if` branches (and the dynamic `import()` inside them) before those edges enter the module graph, so an env-gated dependency subtree is never collected. This is how ink's `if (process.env['DEV'] === 'true') { await import('./devtools.js') }` drops the entire ws / react-devtools subtree (a missing zlib const #463 and the #2310 module-let codegen bug live in there). - env_fold.rs: a build-time constant folder over `if` conditions. Resolves `process.env.<NAME>` from explicit `perry.define` (esbuild-style, host package.json only) plus an implicit `NODE_ENV → "production"` default for node_modules code. Folds Compare/Logical/Unary(Not) over literals; strict eq/ne are total, loose eq/ne + ordering only fold within matching types (never replicates full JS coercion). Anything not provably constant is left untouched — never folds an un-configured runtime env read. Walks module init + function bodies + nested blocks (if/while/for/do/try/switch/labeled). Runs before the dynamic-import walk so the dead `import()` is gone first. Gated on tree-shaking (no-op when off). - expr_member.rs: `process.env["NAME"]` with a string-literal key now lowers to `EnvGet` (static), matching the dot form, so the fold sees ink's bracket access. Runtime-identical to the prior EnvGetDynamic(String) path. Validated: an env-gated dead `import()` of a `new Function` module fails without a define (branch kept → leaf reached) and compiles+runs with `define: { "process.env.DEV": "false" }` (branch + leaf eliminated), with both dot and bracket access. 3 fold unit tests; full perry-hir suite green. Refs #2309
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Implements the module-graph dead-code-elimination phase from #2309 — the bundler-equivalent step Perry was missing. Both stages, behind a default-OFF flag (
PERRY_TREE_SHAKE=1orperry.experiments.treeShake: true). With the flag off, behaviour is byte-identical to before.This is general
compilePackagesinfrastructure (helps Hono/Drizzle/Effect/any npm tree), surfaced by the #348 ink recheck: Perry compiles every import-reachable module and hard-errors onnew Function/ unimplemented APIs in dead transitive code that never runs.Stage 1 — binding-level reachability +
sideEffectspruneperry-hir/src/deferral.rs): while lowering anode_modulesmodule under tree-shaking,new Function(eval_classifier) and the 5#463unimplemented gates record the refusal and fall through instead of hard-erroring. Re-raised after pruning only for modules that survive — dead code's refusals are dropped, live code's stay fatal and span-precise.compile/reachability.rs): monotone Rooted/Bindings lattice. User (non-node_modules) modules seed Rooted, so user code is never pruned — we only ever drop dead dependencies. Honors package.json"sideEffects": false: a pure re-export barrel reached only via named bindings follows just the edges feeding the needed exports and drops bare side-effect imports. Every ambiguous shape (namespace import,export *, dynamicimport(), unresolvable specifier) conservatively roots the whole target. Surviving barrels have dangling edges to pruned modules rewritten away.Stage 2 —
process.envdefine-folding + dead-branch eliminationcompile/env_fold.rs: a build-time constant folder overifconditions, run before dynamic-import()edges are registered, so an env-gated deadimport()never enters the graph. Resolvesprocess.env.<NAME>from explicitperry.define(esbuild-style, host package.json only) plus an implicitNODE_ENV → "production"default fornode_modulescode. Folds Compare/Logical/Unary over literals; strict eq/ne total, loose eq/ne + ordering only within matching types (never replicates full JS coercion); anything not provably constant is left as a runtime read.expr_member.rs:process.env["NAME"]with a string-literal key now lowers toEnvGet(matching the dot form), so the fold sees ink's bracket access. Runtime-identical to the priorEnvGetDynamic(String)path.Validation
import { throttle } from "es-toolkit/compat"(the headline CASE A): tree-shaking prunes 430 unreachable modules includingtemplate.mjs(thenew Functionleaf) and the program compiles + runs. Flag-off, the same import hits thenew Functionwall.import()(CASE B): anew Functionmodule behindif (process.env['DEV'] === 'true')fails without a define (branch kept → leaf reached) and compiles+runs withdefine: { "process.env.DEV": "false" }— both dot and bracket access.export*-keeps-source / non-barrel-escalate) + 3 env-fold + deferral. Fullperry-hirsuite (116 tests) green;cargo fmtclean.Scope / non-goals
ink(React-based TUI framework) end-to-end viaperry.compilePackages#348 stays a smoke test;perry/tuiremains the product TUI path.sideEffects:falsere-export barrels (the only sub-module pruning); ternaryimport()gating and closures nested inside expressions are left for follow-up.wsmodule is in the env-gated dead branch).Closes #2309