fix(minifier): avoid stale pure function summaries (#24636)

Pure-function summaries trusted resolved read and write references, but redeclarations, direct `eval`, and classic Script globals can replace a binding without producing such a reference. A later call could therefore be folded according to an earlier empty declaration and lose runtime side effects.

## Example

```js
function foo() {}
eval("foo = () => side_effect()")
foo()
```

Bindings with redeclarations, bindings visible to direct eval, and classic Script-root bindings do not receive a reusable summary; the eval restriction is intentionally non-sticky so removing the final eval can enable the optimization in a later pass. Module and CommonJS bindings retain the existing fold.

The PR tip passed the full `oxc_minifier` suite independently. The completed stack also passed clippy with warnings denied, formatting, minsize and allocation regeneration, and `just ready`.

## Stack

1. **#24636 — function-summary correctness**
2. #24637 — explicit symbol effect types
3. #24638 — grouped reference counts
4. #24639 — merged persistent metadata
5. #24640 — SymbolState facade

Implemented with AI assistance (OpenAI Codex). The contributor remains responsible for reviewing, understanding, and submitting the changes under the repository AI usage policy.
This commit is contained in:
Dunqing committed 2026-07-18 16:52:41 +00:00
1 parent 54cc4415af
commit 061af1f9c6
4 files changed
+85 -4

No files matched your search

@@ -496,6 +496,20 @@ impl<'a> PeepholeOptimizations {
return;
}
let Some(symbol_id) = id.and_then(|id| id.symbol_id.get()) else { return };
let binding_scope_id = ctx.scoping().symbol_scope_id(symbol_id);
let binding_scope_flags = ctx.scoping().scope_flags(binding_scope_id);
// These bindings can be replaced without producing a resolved write
// reference: redeclarations are span-only in semantic, direct eval can
// assign through the scope chain, and Script globals alias properties
// of the global object. A different function may therefore win at
// runtime even when this declaration is pure.
if !ctx.scoping().symbol_redeclarations(symbol_id).is_empty()
|| binding_scope_flags.contains_direct_eval()
|| (ctx.source_type().is_script() && binding_scope_id == ctx.scoping().root_scope_id())
{
ctx.state.pure_functions.remove(&symbol_id);
return;
}
if ctx.scoping().get_resolved_references(symbol_id).all(|r| r.flags().is_read_only()) {
ctx.state.pure_functions.insert(
symbol_id,
@@ -893,7 +893,9 @@ fn dce_keeps_sloppy_duplicate_block_functions() {
#[test]
fn dce_keeps_script_root_var_in_nested_statement_after_cycle_removed() {
let source = "function outer() { function d1() { return x + d2() } function d2() { return d1() } return 1 } outer(); switch (1) { case 1: var x = 42; }";
let expected = "function outer() { return 1 } switch (1) { case 1: var x = 42; }";
// Script globals can be rebound through global-object properties without a
// resolved write reference, so the call cannot reuse a pure summary.
let expected = "function outer() { return 1 } outer(); switch (1) { case 1: var x = 42; }";
test_source_type(source, expected, SourceType::script());
// CommonJS top-level vars are wrapper-local, so ordinary counts may remove them.
@@ -1,6 +1,8 @@
use oxc_span::SourceType;
use crate::{
CompressOptions, CompressOptionsUnused, default_options, test, test_options, test_same,
test_same_options,
CompressOptions, CompressOptionsUnused, default_options, test, test_options,
test_options_source_type, test_same, test_same_options, test_same_options_source_type,
};
#[track_caller]
@@ -287,3 +289,64 @@ fn remove_empty_function() {
test_same_options("function* foo({}) {} foo()", &options);
test_options("var foo = function*({}) {}; foo()", "(function*({}) {})()", &options);
}
#[test]
fn redeclared_pure_function_is_not_folded_var() {
test_same("var foo = (u) => {}; if (g) var foo = (a) => { console.log(a); }; foo('x');");
}
#[test]
fn redeclared_pure_function_is_not_folded_function_declaration() {
test_same_options_source_type(
"function foo(u) {} function foo(a) { console.log(a); } foo('x');",
SourceType::cjs().with_script(true),
&default_options(),
);
}
#[test]
fn direct_eval_rebound_function_is_not_folded() {
test(
"function foo() {} eval(\"foo = () => side_effect()\"); foo();",
"function foo() {} eval(\"foo = () => side_effect()\"), foo();",
);
}
#[test]
fn nested_direct_eval_rebound_function_is_not_folded() {
test(
"function foo() {} function g() { eval(\"foo = () => side_effect()\") } g(); foo();",
"function foo() {} function g() { eval(\"foo = () => side_effect()\") } g(), foo();",
);
}
#[test]
fn removed_direct_eval_reenables_pure_function_folding() {
test_options_source_type(
"function foo() {} if (0) eval(\"foo = () => side_effect()\"); foo();",
"",
SourceType::cjs(),
&CompressOptions::smallest(),
);
}
#[test]
fn script_root_rebound_function_is_not_folded() {
test_options_source_type(
"function foo() {} globalThis.foo = () => side_effect(); foo();",
"function foo() {} globalThis.foo = () => side_effect(), foo();",
SourceType::cjs().with_script(true),
&default_options(),
);
}
#[test]
fn non_redeclared_pure_function_still_folds() {
test("const foo = (u) => {}; foo(1)", "const foo = (u) => {};");
test_options_source_type(
"function foo() {} foo()",
"function foo() {}",
SourceType::cjs(),
&default_options(),
);
}
@@ -856,7 +856,9 @@ fn keep_sloppy_annex_b_alias_member_write_after_cycle_removed() {
fn keep_script_root_var_in_nested_statement_after_cycle_removed() {
let options = CompressOptions::smallest();
let source = "function outer() { function d1() { return x + d2() } function d2() { return d1() } return 1 } outer(); switch (1) { case 1: var x = 42; }";
let expected = "function outer() { return 1 } switch (1) { case 1: var x = 42; }";
// Script globals can be rebound through global-object properties without a
// resolved write reference, so the call cannot reuse a pure summary.
let expected = "function outer() { return 1 } switch (outer(), 1) { case 1: var x = 42; }";
test_options_source_type(source, expected, SourceType::script(), &options);
// CommonJS top-level vars are wrapper-local, so ordinary counts may remove them.