feat(semantic): store scope_id in Reference#18053
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
|
3ce0bac to
c8262de
Compare
There was a problem hiding this comment.
Pull request overview
This pull request adds a scope_id field to the Reference struct to store the scope where each reference occurs, simplifying use cases that need to determine the scope of a reference. The change updates the core semantic analysis infrastructure and propagates the modification through all affected code paths and test snapshots.
Changes:
- Added
scope_id: ScopeIdfield to theReferencestruct with corresponding getter method - Updated
Reference::new()andReference::new_with_symbol_id()constructors to acceptscope_idparameter - Modified all call sites creating
Referenceinstances to pass the current scope ID - Updated test snapshot generation to include
scope_idin output - Regenerated all test snapshots to reflect the new field
Reviewed changes
Copilot reviewed 254 out of 254 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| crates/oxc_syntax/src/reference.rs | Added scope_id field to Reference struct, updated constructors and added getter method |
| crates/oxc_traverse/src/context/scoping.rs | Updated create_reference and create_unbound_reference to pass self.current_scope_id |
| crates/oxc_semantic/src/builder.rs | Updated reference_identifier to pass self.current_scope_id when creating references |
| crates/oxc_semantic/tests/main.rs | Modified snapshot generation to include scope_id field in test output |
| crates/oxc_semantic/tests/fixtures/**/*.snap | Regenerated all snapshot files to include scope_id for each reference |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
c8262de to
a4f011a
Compare
|
I'd like to get this ship today to finish Rolldown's PR. |
Merge activity
|
rolldown/rolldown#7936 relies on this. You can see how Rolldown uses this at description of that PR. Additionally, it can also simplify the use case when you want to get the scope ID where the `Reference` occurs. See follow-up PR #18059
a4f011a to
e2221e6
Compare
Note: waiting for oxc-project/oxc#18053 to merge to unblock this (Edit: merged, and already released) ## Summary Fixes: #6586 This PR refines the symbol renaming (deconflict) logic to correctly handle both **top-level symbol conflicts** and **nested scope shadowing**. The key insight is that these are two distinct problems requiring different solutions. Related fixes: - #7859 - #7867 - #7899 This PR essentially reverts all the logic implemented in the previous fixes while preserving the performance improvements introduced in #7867 (4%). --- ## 1. Top-Level Symbol Renaming When bundling multiple modules into a single chunk, symbols with the same name need unique identifiers. **Example:** ```js // lib.js export const value = 1; // other.js export const value = 2; // main.js import { value } from './lib.js'; import { value as otherValue } from './other.js'; console.log(value, otherValue); ``` **Output:** ```js const value = 1; // lib.js - keeps original name (entry module priority) const value$1 = 2; // other.js - renamed to avoid conflict console.log(value, value$1); ``` **Rules:** - Entry module symbols get naming priority - Subsequent conflicting symbols get suffixed: `$1`, `$2`, etc. - Reserved names (keywords, globals like `Object`, `Promise`) are always avoided --- ## 2. Nested Scope Symbol Renaming This handles cases where a nested binding (function parameter, catch clause, block-scoped variable) interacts with top-level symbols. ### Case A: NO renaming needed - Intentional shadowing When a nested binding shadows an import that **kept its original name**, no renaming is needed because JavaScript's scoping rules handle it correctly. **Example (`preserve_shadowing_param_name`):** ```js // lib.js export const Client = []; // main.js import { Client } from './lib.js'; // This param shadows the imported `Client`, but should NOT be renamed // since shadowing is intentional and doesn't cause conflicts at runtime. const Config = (Client) => { console.log(Client); // → refers to parameter }; console.log(Client); // → refers to import ``` **Output:** ```js const Client = []; const Config = (Client) => { // Parameter keeps its name ✓ console.log(Client); }; console.log(Client); ``` --- ### Case B: Star import member references - Renaming needed When a namespace import member (like `ns.foo`) is referenced inside a function, and a nested binding would capture that reference, the nested binding must be renamed. **Example (`argument-treeshaking-parameter-conflict`):** ```js // dep.js export let value = 0; export const mutate = () => value++; // main.js import * as dep from './dep'; function test(mutate) { // Parameter named 'mutate' dep.mutate('hello'); // Reference to dep.mutate (top-level) } test(); assert.strictEqual(dep.value, 1); ``` **Output:** ```js let value = 0; const mutate = () => value++; function test(mutate$1) { // Parameter renamed to mutate$1 mutate("hello"); // Correctly calls top-level mutate } test(); assert.strictEqual(value, 1); ``` **Why renaming is needed:** 1. The namespace import `dep.mutate` resolves to the top-level `mutate` function 2. Inside `test`, the parameter `mutate` would shadow the top-level `mutate` 3. The reference `dep.mutate('hello')` becomes just `mutate("hello")` after bundling 4. Without renaming the parameter, this call would incorrectly invoke the parameter instead of the top-level function 5. Solution: Rename the parameter to `mutate$1` so the reference correctly resolves to the top-level `mutate` --- ### Case C: Named imports - Renaming needed When a named import is renamed due to a top-level conflict, and a nested binding has the same name as the **renamed** import, that nested binding must be renamed to avoid capturing references. **Example (`basic_scoped`):** ```js // a.js const a = 'a.js'; export { a }; // main.js import { a as aJs } from './a'; const a = 'main.js'; // Local 'a' takes priority (entry module) function foo(a$1) { // Parameter named 'a$1' return [a$1, a, aJs]; // References: param, local, import } assert.deepEqual(foo('foo'), ['foo', 'main.js', 'a.js']); ``` **Output:** ```js const a$1 = "a.js"; // Import renamed (conflicts with local 'a') const a = "main.js"; // Local keeps name (entry module priority) function foo(a$1$1) { // Parameter renamed to a$1$1 return [a$1$1, a, a$1]; // Correctly resolves all three references } assert.deepEqual(foo("foo"), ["foo", "main.js", "a.js"]); ``` **Why renaming is needed:** 1. `a` from `a.js` is renamed to `a$1` (conflicts with local `a` in entry module) 2. The alias `aJs` resolves to the renamed `a$1` 3. Inside `foo`, the parameter `a$1` would capture the reference to `aJs` 4. Solution: Rename the parameter to `a$1$1` so `aJs` correctly resolves to the top-level `a$1` --- > **Technical Note:** Cases B and C use the same underlying mechanism to detect shadowing: > > 1. Get the `reference_id` from the reference site (where the symbol is used) > 2. Use `scoping.scope_ancestors(reference.scope_id())` to walk up the scope chain > 3. Check if any ancestor scope has a binding with the same name as the renamed symbol > 4. If found, rename that nested binding to avoid capture > > This detection relies on oxc-project/oxc#18053 which added `scope_id` to `Reference`, enabling us to locate the exact scope where the reference occurs. --- ### Case D: CJS wrapper parameters - Renaming needed For CommonJS wrapped modules, nested scopes must avoid shadowing the synthetic `exports` and `module` parameters injected by the CJS wrapper. **Example:** ```js // cjs-module.js (detected as CommonJS) function helper() { const exports = {}; // Would shadow CJS wrapper's exports parameter return exports; } module.exports = helper; ``` **Output:** ```js var require_cjs_module = __commonJS((exports, module) => { function helper() { const exports$1 = {}; // Renamed to avoid shadowing return exports$1; } module.exports = helper; }); ``` --- ## Implementation Details The renaming happens in two phases: 1. **`add_symbol_in_root_scope`** - Assigns canonical names to top-level symbols, checking: - Is the name already used by another top-level symbol? - For facade symbols (external module namespaces): would it conflict with nested scopes in the entry module? 2. **`NestedScopeRenamer`** - Renames nested bindings that would capture references: - **`rename_bindings_shadowing_star_imports`** (Case B): Star import member references (`ns.foo`) - **`rename_bindings_shadowing_named_imports`** (Case C): Named imports that were renamed - **`rename_bindings_shadowing_cjs_params`** (Case D): CJS wrapper parameter shadowing (`exports`, `module`) --- ## Test Cases | Test | Case | Description | |------|------|-------------| | `preserve_shadowing_param_name` | A | Parameter shadows import, keeps its name (intentional) | | `argument-treeshaking-parameter-conflict` | B | Namespace member ref, parameter renamed | | `basic_scoped` | C | Named import renamed, nested binding renamed | | `cjs` | D | CJS wrapper parameter shadowing | | Rollup: `preserves-catch-argument` | A | Catch parameter shadows import, keeps its name | | Rollup: `class-name-conflict-2` | A | Class in block scope, keeps its name | > Note for reviewer: Code comments and PR description are almost generated by Claude Code, but with careful self-review.

rolldown/rolldown#7936 relies on this. You can see how Rolldown uses this at description of that PR.
Additionally, it can also simplify the use case when you want to get the scope ID where the
Referenceoccurs. See follow-up PR #18059