fix(semantic): resolve parameter initializer references after traversal#24033
fix(semantic): resolve parameter initializer references after traversal#24033Dunqing wants to merge 2 commits into
Conversation
How to use the Graphite Merge QueueAdd either label to this PR to merge it via the merge queue:
You must have a Graphite account in order to use the merge queue. Sign up using this link. An organization admin has enabled the Graphite Merge Queue in this repository. Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue. This stack of pull requests is managed by Graphite. Learn more about stacking. |
Merging this PR will not alter performance
Comparing Footnotes
|
Monitor OxcCommit:
|
7de888e to
829a773
Compare
`collect_exported_symbols` resolved each exported variable declarator with `get_binding_identifier`, which returns `None` for destructuring patterns — so the bindings of `export const { … } = …` were never marked as exported and got mangled, **renaming the module's named exports**. Collect the declarator's `bound_names` instead, which covers object / array patterns, nesting, defaults and rest elements.
Surfaced by the monitor-oxc mangler runtime test (previously masked by the idempotency failures fixed in #24033): mangled `css-tree` lost its `find` export and `@asamuzakjp/dom-selector` failed with `SyntaxError: The requested module 'css-tree' does not provide an export named 'find'` ([failing run](https://github.com/oxc-project/monitor-oxc/actions/runs/28562614557/job/84683456673)). The same export shape appears in commander, csso, colorette, ansis and `@actions/io`, so those were broken too.
## Example
```js
import syntax from "./syntax/index.js";
export const { tokenize, parse, find } = syntax;
```
Before:
```js
import e from "./syntax/index.js";
export const { tokenize: t, parse: n, find: s } = e; // exports `t`, `n`, `s`
```
After:
```js
import e from "./syntax/index.js";
export const { tokenize, parse, find } = e;
```
Verified end-to-end by mangling every file in `[email protected]` and exercising `import { find, parse, generate, walk } from "css-tree"` in node.
829a773 to
ee81044
Compare
…ed (#24041) Adds `MangleOptions::reserved` — equivalent to terser / uglify-js / swc `mangle.reserved`: names in the set are never used as mangled names, and bindings that already carry them keep them. Default empty; default output is byte-identical to main (minsize snapshot unchanged). ## Motivation Node's cjs-module-lexer detects a CommonJS module's named exports by **lexically** scanning for `exports.<name> =` / `module.exports` token patterns — no scope analysis ([its README documents renamed identifiers as "DETECTS: NO EXPORTS"](https://github.com/nodejs/cjs-module-lexer)). UMD / CommonJS wrappers bind `exports` / `module` as ordinary function parameters, and the mangler renames them, erasing every named export the lexer can see. Surfaced by the monitor-oxc mangler runtime test ([failing run](https://github.com/oxc-project/monitor-oxc/actions/runs/28569108563/job/84702922472), previously masked by #24033 / #24036): `archiver` does `import { queue } from "async"`, and `async`'s dist is a UMD bundle — mangled, the factory parameter `exports` became `e` and node fails with `Named export 'queue' not found`. With `reserved: ["exports", "module"]` the output imports correctly (verified end-to-end in node, including running a queue task). ## Why an opt-in list instead of always keeping these names Renaming them is ecosystem-standard. Verified empirically — minifying the same UMD sample and importing it in node: | minifier | renames `exports` param? | `import { queue }` from its output | escape hatch | | --- | --- | --- | --- | | terser 5.48 | yes | fails | `mangle.reserved` (verified working) | | uglify-js 3.19 | yes | fails | `reserved` (verified working) | | esbuild 0.28 `--minify` | yes | fails | none for identifier mangling | | swc | yes | fails | terser-style `mangle.reserved` ([swc#3424](swc-project/swc#3424)) | An earlier revision of this PR kept `exports` / `module` unconditionally; the Minification Size check showed the real cost: ~1% minified on UMD-heavy files (d3: +3.3 kB from ~590 `exports` references, flipping it to *larger than esbuild*), because the point is keeping the literal 7-char token at every export site. No other minifier pays this by default — and rolldown bundles bind `exports` / `module` in every CJS interop wrapper, where node's lexer never looks. So oxc gets the same escape hatch as terser/swc, with the same name. `MangleOptions` loses `Copy` (it now holds the set); construction sites switch to `..MangleOptions::default()` / `clone()`. Exposed in `oxc-minify` NAPI options (`mangle.reserved: string[]`) and the mangler example (`--reserve-exports`). monitor-oxc will pass `["exports", "module"]` for its mangler runtime test (its corpus imports prebuilt CJS/UMD dists directly). For reference, esbuild solves this for its *own* node-platform CJS bundles differently — a dead-code annotation `0 && (module.exports = { a, b })` that cjs-module-lexer recognizes ([esbuild#960](evanw/esbuild#960), [cjs-module-lexer#44](nodejs/cjs-module-lexer#44)); verified that pattern also works appended to fully-mangled output. That could be a future zero-size-cost alternative at the minifier level.
References in function / catch parameter initializers were early-resolved
by walking up the full scope chain mid-traversal, while ancestor scopes
were still being built. A binding declared later in an enclosing scope —
a hoisted `function`, or a `let`/`const` after the statement — was not
bound yet, so the reference permanently captured a same-named symbol
further out that should have been shadowed:
```js
function s() { return "outer"; }
function m() {
const a = (r = s) => r();
return a();
function s() { return "inner"; }
}
m(); // spec: "inner" — the reference was resolved to the outer `s`
```
The mangler then renamed the declaration and the reference independently,
miscompiling the program (mangled output printed "outer") and breaking
mangler idempotency in monitor-oxc on ts-api-utils, typescript, vuetify
and storybook.
Each entry in the flat unresolved-references list now carries the scope
its resolution walk starts from (free — the tuple stays 24 bytes). Early
resolution only checks the scopes inside the parameter region, from the
walk-start scope up to and including the function scope — all complete
at that point. An unresolved reference's walk-start is bumped to the
function's *parent* scope, so the final resolution walk starts above the
function scope once every binding exists — body bindings, added to the
shared function scope later, stay invisible to it, and an enclosing
parameter region re-processes it naturally at its own boundary. The
reference's own `scope_id` is never touched, so liveness analyses
(mangler slot reuse) stay exact.
Two conformance snapshot entries change: the object-rest-spread transform
(and the equivalent TS case) relocates destructured parameters into the
function body while keeping references to them in parameter initializers,
so the rebuilt semantic — now spec-correct — no longer reproduces the
transformer's retained symbol links. That transform output was already
semantically broken JS (a known Babel limitation); the mismatch is now
visible instead of accidentally hidden. Remaining snapshot changes are
reference-id orderings inside already-mismatching entries: references
from parameter regions now resolve in creation order.
ee81044 to
f165b8e
Compare
Regression tests for the semantic fix in the previous commit: a parameter initializer referencing a hoisted `function` (or later `let`/`const`) in the enclosing function's body must follow that binding, not a same-named outer symbol, and re-mangling the output must be a fixed point.
f165b8e to
c4505e3
Compare
Node's cjs-module-lexer detects a CommonJS module's named exports by
lexically scanning for `exports.<name> =` / `module.exports` token
patterns — no scope analysis. UMD / CommonJS wrappers bind `exports` /
`module` as ordinary function parameters, and mangling renames them,
erasing every named export the lexer can see: `import { queue } from
"async"` fails the mangler runtime test ([failing
run](https://github.com/oxc-project/monitor-oxc/actions/runs/28569108563/job/84702922472)).
Renaming these is ecosystem-standard (esbuild / terser / swc all do it
by default), so oxc keeps its default behavior and instead provides
`MangleOptions::reserved` — the terser `mangle.reserved` equivalent,
added in oxc-project/oxc#24041. This suite imports prebuilt dists
directly, so pass `["exports", "module"]`.
Depends on oxc-project/oxc#24041 (merged). The mangler job also needs
oxc-project/oxc#24033 to pass its idempotency phase.
`collect_exported_symbols` resolved each exported variable declarator with `get_binding_identifier`, which returns `None` for destructuring patterns — so the bindings of `export const { … } = …` were never marked as exported and got mangled, **renaming the module's named exports**. Collect the declarator's `bound_names` instead, which covers object / array patterns, nesting, defaults and rest elements.
Surfaced by the monitor-oxc mangler runtime test (previously masked by the idempotency failures fixed in #24033): mangled `css-tree` lost its `find` export and `@asamuzakjp/dom-selector` failed with `SyntaxError: The requested module 'css-tree' does not provide an export named 'find'` ([failing run](https://github.com/oxc-project/monitor-oxc/actions/runs/28562614557/job/84683456673)). The same export shape appears in commander, csso, colorette, ansis and `@actions/io`, so those were broken too.
## Example
```js
import syntax from "./syntax/index.js";
export const { tokenize, parse, find } = syntax;
```
Before:
```js
import e from "./syntax/index.js";
export const { tokenize: t, parse: n, find: s } = e; // exports `t`, `n`, `s`
```
After:
```js
import e from "./syntax/index.js";
export const { tokenize, parse, find } = e;
```
Verified end-to-end by mangling every file in `[email protected]` and exercising `import { find, parse, generate, walk } from "css-tree"` in node.
…ed (#24041) Adds `MangleOptions::reserved` — equivalent to terser / uglify-js / swc `mangle.reserved`: names in the set are never used as mangled names, and bindings that already carry them keep them. Default empty; default output is byte-identical to main (minsize snapshot unchanged). ## Motivation Node's cjs-module-lexer detects a CommonJS module's named exports by **lexically** scanning for `exports.<name> =` / `module.exports` token patterns — no scope analysis ([its README documents renamed identifiers as "DETECTS: NO EXPORTS"](https://github.com/nodejs/cjs-module-lexer)). UMD / CommonJS wrappers bind `exports` / `module` as ordinary function parameters, and the mangler renames them, erasing every named export the lexer can see. Surfaced by the monitor-oxc mangler runtime test ([failing run](https://github.com/oxc-project/monitor-oxc/actions/runs/28569108563/job/84702922472), previously masked by #24033 / #24036): `archiver` does `import { queue } from "async"`, and `async`'s dist is a UMD bundle — mangled, the factory parameter `exports` became `e` and node fails with `Named export 'queue' not found`. With `reserved: ["exports", "module"]` the output imports correctly (verified end-to-end in node, including running a queue task). ## Why an opt-in list instead of always keeping these names Renaming them is ecosystem-standard. Verified empirically — minifying the same UMD sample and importing it in node: | minifier | renames `exports` param? | `import { queue }` from its output | escape hatch | | --- | --- | --- | --- | | terser 5.48 | yes | fails | `mangle.reserved` (verified working) | | uglify-js 3.19 | yes | fails | `reserved` (verified working) | | esbuild 0.28 `--minify` | yes | fails | none for identifier mangling | | swc | yes | fails | terser-style `mangle.reserved` ([swc#3424](swc-project/swc#3424)) | An earlier revision of this PR kept `exports` / `module` unconditionally; the Minification Size check showed the real cost: ~1% minified on UMD-heavy files (d3: +3.3 kB from ~590 `exports` references, flipping it to *larger than esbuild*), because the point is keeping the literal 7-char token at every export site. No other minifier pays this by default — and rolldown bundles bind `exports` / `module` in every CJS interop wrapper, where node's lexer never looks. So oxc gets the same escape hatch as terser/swc, with the same name. `MangleOptions` loses `Copy` (it now holds the set); construction sites switch to `..MangleOptions::default()` / `clone()`. Exposed in `oxc-minify` NAPI options (`mangle.reserved: string[]`) and the mangler example (`--reserve-exports`). monitor-oxc will pass `["exports", "module"]` for its mangler runtime test (its corpus imports prebuilt CJS/UMD dists directly). For reference, esbuild solves this for its *own* node-platform CJS bundles differently — a dead-code annotation `0 && (module.exports = { a, b })` that cjs-module-lexer recognizes ([esbuild#960](evanw/esbuild#960), [cjs-module-lexer#44](nodejs/cjs-module-lexer#44)); verified that pattern also works appended to fully-mangled output. That could be a future zero-size-cost alternative at the minifier level.

References in function / catch parameter initializers were early-resolved by walking up the full scope chain mid-traversal, while ancestor scopes were still being built. A binding declared later in an enclosing scope — a hoisted
function, or alet/constafter the statement — was not bound yet, so the reference permanently captured a same-named symbol further out that should have been shadowed. The mangler then renamed the declaration and the reference independently, miscompiling the program. This is the root cause of the monitor-oxc mangler idempotency failures on ts-api-utils / typescript / vuetify / storybook (all 13 failing files pass after this fix).Example
Before (mangled output —
r = epoints at the top-level function):After:
How
Each entry in the flat unresolved-references list now carries the scope its resolution walk starts from — the reference's own scope, initially. This is free: the tuple is 24 bytes with or without the extra
ScopeId(padding), and no allocation profile changes.The early resolution at a parameter-region boundary only checks the scopes inside the region — from the walk-start scope up to and including the function scope. Those are complete at that point: nested initializer scopes are fully visited, and the function scope holds exactly the parameters, so a match can never be a body binding. An unresolved reference just gets its walk-start bumped to the function's parent scope:
var.scope_idis never touched, so liveness analyses (mangler slot reuse) stay exact.Conformance
Two snapshot entries change (one
semantic_typescript, one Babelobject-rest-spreadfixture): the object-rest parameter transform relocates destructured parameters into the function body while keeping references to them in parameter initializers, so the rebuilt semantic — now spec-correct — no longer reproduces the transformer's retained symbol links. That transform output is already semantically broken JS (a known Babel limitation, e.g.(_ref, a = R) => { let R = ... }); the mismatch is now visible instead of accidentally hidden. TypeScript agrees:function f(): X { type X = number; ... }isTS2304in tsc, which this change also aligns with.The remaining snapshot changes are reference-id orderings inside already-mismatching entries: parameter-region references now resolve in creation order.