Skip to content

feat(compile): module-graph tree-shaking / DCE for compilePackages (#2309) - #2325

Merged
proggeramlug merged 2 commits into
mainfrom
feat/tree-shake-2309
May 29, 2026
Merged

proggeramlug merged 2 commits into
mainfrom
feat/tree-shake-2309

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

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=1 or perry.experiments.treeShake: true). With the flag off, behaviour is byte-identical to before.

This is general compilePackages infrastructure (helps Hono/Drizzle/Effect/any npm tree), surfaced by the #348 ink recheck: Perry compiles every import-reachable module and hard-errors on new Function / unimplemented APIs in dead transitive code that never runs.

Stage 1 — binding-level reachability + sideEffects prune

  • Deferred refusals (perry-hir/src/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 — dead code's refusals are dropped, live code's stay fatal and span-precise.
  • Reachability fixpoint (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 *, dynamic import(), unresolvable specifier) conservatively roots the whole target. Surviving barrels have dangling edges to pruned modules rewritten away.

Stage 2 — process.env define-folding + dead-branch elimination

  • compile/env_fold.rs: a build-time constant folder over if conditions, run before dynamic-import() edges are registered, so an env-gated dead import() never enters the graph. 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 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 to EnvGet (matching the dot form), so the fold sees ink's bracket access. Runtime-identical to the prior EnvGetDynamic(String) path.

Validation

  • Real import { throttle } from "es-toolkit/compat" (the headline CASE A): tree-shaking prunes 430 unreachable modules including template.mjs (the new Function leaf) and the program compiles + runs. Flag-off, the same import hits the new Function wall.
  • Env-gated dead import() (CASE B): a new Function module behind if (process.env['DEV'] === 'true') fails without a define (branch kept → leaf reached) and compiles+runs with define: { "process.env.DEV": "false" } — both dot and bracket access.
  • Flag-off path verified byte-identical on plain programs.
  • Unit tests: 4 reachability fixpoint (barrel-drop / namespace-keeps-all / export*-keeps-source / non-barrel-escalate) + 3 env-fold + deferral. Full perry-hir suite (116 tests) green; cargo fmt clean.

Scope / non-goals

Closes #2309

Ralph Küpper 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
@proggeramlug
proggeramlug merged commit 232b712 into main May 29, 2026
11 checks passed
@proggeramlug
proggeramlug deleted the feat/tree-shake-2309 branch May 29, 2026 05:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Module-graph dead-code elimination: named-import tree-shaking + build-time env-branch folding for compilePackages

1 participant