Skip to content

fix(semantic): resolve parameter initializer references after traversal#24033

Draft
Dunqing wants to merge 2 commits into
mainfrom
fix/semantic-param-initializer-resolution
Draft

fix(semantic): resolve parameter initializer references after traversal#24033
Dunqing wants to merge 2 commits into
mainfrom
fix/semantic-param-initializer-resolution

Conversation

@Dunqing

@Dunqing Dunqing commented Jul 2, 2026

Copy link
Copy Markdown
Member

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. 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

function s() { return "outer"; }
function m() {
	const a = (r = s) => r();
	return a();
	function s() { return "inner"; }
}
console.log(m());

Before (mangled output — r = e points at the top-level function):

function e() { return "outer"; }
function t() {
	const t = (t = e) => t();
	return t();
	function n() { return "inner"; }
}
console.log(t()); // prints "outer" — behavior changed

After:

function e() { return "outer"; }
function t() {
	const e = (e = t) => e();
	return e();
	function t() { return "inner"; }
}
console.log(t()); // prints "inner" — matches source

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:

  • The final resolution pass (unchanged in shape) then starts above the function scope once every binding exists — body bindings, added to the shared function scope later, stay invisible, per spec. This also fixes the reverse case, where such a reference used to fall back to a body var.
  • Nested parameter regions need no special handling: the reference is still in the flat list, so the enclosing region's own boundary pass re-processes it (initializers do see the enclosing function's parameters).
  • The reference's own scope_id is never touched, so liveness analyses (mangler slot reuse) stay exact.

Conformance

Two snapshot entries change (one semantic_typescript, one Babel object-rest-spread fixture): 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; ... } is TS2304 in 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.

Dunqing commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

How to use the Graphite Merge Queue

Add either label to this PR to merge it via the merge queue:

  • 0-merge - adds this PR to the back of the merge queue
  • hotfix - for urgent changes, fast-track this PR to the front of 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.

@github-actions github-actions Bot added A-semantic Area - Semantic A-minifier Area - Minifier A-transformer Area - Transformer / Transpiler labels Jul 2, 2026
@codspeed-hq

codspeed-hq Bot commented Jul 2, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 62 untouched benchmarks
⏩ 9 skipped benchmarks1


Comparing fix/semantic-param-initializer-resolution (c4505e3) with main (8dd81f2)2

Open in CodSpeed

Footnotes

  1. 9 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (dade03e) during the generation of this report, so 8dd81f2 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@Dunqing Dunqing added the run-monitor-oxc Add to a PR to dispatch oxc-project/monitor-oxc CI against it label Jul 2, 2026
@oxc-guard

oxc-guard Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

@oxc-guard oxc-guard Bot removed the run-monitor-oxc Add to a PR to dispatch oxc-project/monitor-oxc CI against it label Jul 2, 2026
@Dunqing
Dunqing force-pushed the fix/semantic-param-initializer-resolution branch 2 times, most recently from 7de888e to 829a773 Compare July 2, 2026 05:43
graphite-app Bot pushed a commit that referenced this pull request Jul 2, 2026
`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.
@Dunqing
Dunqing force-pushed the fix/semantic-param-initializer-resolution branch from 829a773 to ee81044 Compare July 2, 2026 05:58
@Dunqing Dunqing added the run-monitor-oxc Add to a PR to dispatch oxc-project/monitor-oxc CI against it label Jul 2, 2026
@oxc-guard oxc-guard Bot removed the run-monitor-oxc Add to a PR to dispatch oxc-project/monitor-oxc CI against it label Jul 2, 2026
graphite-app Bot pushed a commit that referenced this pull request Jul 2, 2026
…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.
@Dunqing
Dunqing force-pushed the fix/semantic-param-initializer-resolution branch from ee81044 to f165b8e Compare July 2, 2026 14:02
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.
@Dunqing
Dunqing force-pushed the fix/semantic-param-initializer-resolution branch from f165b8e to c4505e3 Compare July 2, 2026 14:03
Dunqing added a commit to oxc-project/monitor-oxc that referenced this pull request Jul 2, 2026
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.
camc314 pushed a commit that referenced this pull request Jul 3, 2026
`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.
camc314 pushed a commit that referenced this pull request Jul 3, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-minifier Area - Minifier A-semantic Area - Semantic A-transformer Area - Transformer / Transpiler

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant