Skip to content

js_parser: key exports.eliminate/replace on the exported name - #33386

Closed
robobun wants to merge 10 commits into
mainfrom
farm/76f700fc/eliminate-exported-name
Closed

robobun wants to merge 10 commits into
mainfrom
farm/76f700fc/eliminate-exported-name

Conversation

@robobun

@robobun robobun commented Jul 5, 2026 •

Copy link
Copy Markdown
Collaborator

Bun.Transpiler's exports.eliminate / exports.replace are keyed on a module's exported names: that is the public surface, and it is what the sibling Bun.Transpiler.scan() reports. Export clauses matched the local name instead, and re-export clauses matched the exported name, so the same build had two opposite conventions and no single name worked for both forms. The miss is silent.

Repro

new Bun.Transpiler({ loader: "ts", exports: { eliminate: ["QA"] } })
  .transformSync(`const q = 1; export { q as QA };`);
// => "const q = 1;\nexport { q as QA };"   (not eliminated; "q" eliminates it)

new Bun.Transpiler({ loader: "ts", exports: { eliminate: ["RR"] } })
  .transformSync(`export { rr as RR } from "./dep";`);
// => "export {  } from \"./dep\";"          (eliminated; "rr" does not)

new Bun.Transpiler({ loader: "ts" })
  .scan(`const q = 1; export { q as QA }; export { rr as RR } from "./d";`).exports;
// => ["QA", "RR"]

Cause

s_export_clause looked the entry up under load_name_from_ref(item.name.ref_), which is the local name being exported, not item.alias. s_export_from already used item.alias, hence the split.

Keying on the alias then exposed a second half of the same defect: a replace entry injects export var <name> = value, and that name came from the local symbol. export { rr as RR } from "./dep" replaced by { RR: 9 } emitted export var rr = 9 on main, i.e. it matched RR but exported rr.

Fix

Match item.alias in both clause forms, and name the injected Replace binding after the alias when it differs from the local name. replacement_export_ref resolves that ref for both forms.

The injected binding is a var, so it is declared Kind::Hoisted rather than Kind::Other. Without this, var QA = 5; var q = 1; export { q as QA }; under replace: { QA: 9 } would report a spurious "QA" has already been declared for valid input; as a hoisted var it merges, exactly like the var that gets printed. A const/let/class of the same name cannot merge, so replacement_export_ref asks can_merge_symbol_kinds what declare_symbol would decide and leaves the clause alone on Forbidden. An import is rejected too: TypeScript merges a var into it rather than refusing it, since the import may be type-only, but both still bind the name and scan_imports then rejects the file. An unbound name is not a declaration, so the injected var still binds it. The same check gates the unrenamed clause, where it lands on the local itself: const foo = 1; export { foo }; and import { foo } from "./d"; export { foo }; used to print export var foo = 9 beside the existing binding.

Emitting export var <alias> also needs the alias to be spellable as a strict-mode binding, and exports.replace keys are only checked with is_identifier, which accepts every keyword. The parser already had that predicate as can_be_class_binding_name in lower_decorators.rs; it now lives in parser.rs as can_be_binding_identifier and both callers share it. A reserved-word alias keeps its export clause, which is what main does for export { q as default }.

That guard runs before the alias == local_name fast path, because an unrenamed re-export carries the exported name too. The fast path itself only reuses local_ref when it is_symbol(): in s_export_from that ref is always the parse-time name ref from store_name_in_ref, so a re-export has no local binding to reuse and Replace has to declare the alias. Without both, export { default } from "./d" and export { RR } from "./dep" under replace reached inject_replacement_export with a non-symbol ref: assertion failed: r#ref.is_symbol() on a debug build, and on release a symbol table indexed by a name ref, which is what segfaults released bun once several such clauses run in one process.

Delete (eliminate) declares nothing, so reserved-word and string-named exports (export { q as default }, export { q as "a-b" }) are still eliminated by their exported name.

exports.replace was typed Record<string, string> while JSTranspiler accepts string | number | boolean | null | undefined, plus a [name, value] pair. The transpiler tests rely on both, so the documented Next.js config ({ getStaticProps: ["__N_SSG", true] }) did not type-check. Widened, with a bun-types fixture that pins the accepted space. The JSDoc also records that replace keys (and the name of a pair) are identifier-validated while eliminate is not, so export { q as "a-b" } can only be eliminated.

Declaration forms (export const/function/class), export default, and export * as ns already keyed on the exported name and are unchanged.

Verification

test/bundler/transpiler/transpiler.test.js, describe("exports.eliminate and exports.replace match the exported name"): 38 tests covering renamed and unrenamed clauses, renamed and unrenamed re-exports, scalar and array-form replace, as default, string-named exports, the local-name non-match, the full collision matrix (var and function merge, const/let/class/import leave the clause alone, unbound binds), and every branch of can_be_binding_identifier (default, class, let, eval, await).

18 of them fail on released bun, and the unrenamed re-export case asserts on any debug build without the is_symbol() guard, which I confirmed by reverting just that line. On this branch the describe block is 38 pass / 0 fail, and test/bundler/transpiler/ + bundler_decorator_metadata.test.ts + bundler_esm.test.ts + bundler_edgecase.test.ts are green at 4317 passing (the decorator suites cover the lower_decorators.rs dedup). test/integration/bun-types/bun-types.test.ts is 12 pass, 0 fail, and fails on the old Record<string, string> annotation, so the new fixture is not vacuous.

Only Bun.Transpiler populates replace_exports; the bundler always passes an empty map, so nothing else changes behavior.

Known gaps, not touched here

All pre-existing, none reachable from the forms this PR rewires:

  • s_export_star declares the alias itself, so export * as default from "./d" under replace: { default: 9 } still emits an invalid export var default = 9, and var ns = 5; export * as ns from "./d" under replace: { ns: 9 } still reports a redeclaration. Routing it through replacement_export_ref would fix both, but it is an untouched path with no reported bug.
  • inject_replacement_export and replace_decl_and_possibly_remove declare their Inject name with Kind::Other, so replace: { foo: ["default", true] } emits an invalid export var default, and two entries injecting the same name (the getStaticProps/getStaticPaths → __N_SSG config in this repo's own tests) error. Making those merge raises a separate question about whether a duplicate Inject should dedupe.

Bun.Transpiler's exports.eliminate and exports.replace are keyed on the
exported name, which is the module's public surface and what
Bun.Transpiler.scan() reports. Export clauses matched the local name
instead, so `export { q as QA }` was missed by eliminate: ["QA"] and
removed by eliminate: ["q"]. Re-export clauses already matched the
exported name, giving the same build two opposite conventions.

Match the alias in both clause forms, and name the var that a replace
entry injects after the alias too: `export { rr as RR } from "./dep"`
replaced by { RR: expr } emitted `export var rr` rather than RR.

The injected binding is a var, so declare it hoisted; that also lets a
local of the same name merge with it instead of reporting a spurious
"already been declared".
@robobun
robobun requested a review from alii as a code owner July 5, 2026 17:39
@coderabbitai

coderabbitai Bot commented Jul 5, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR changes exports.eliminate and exports.replace to match exported names, adds binding-identifier validation for replacement generation, and updates type declarations, runtime handling, decorator validation, and tests.

Changes

Exported-name matching implementation

Layer / File(s) Summary
Binding identifier helper and replacement ref
src/js_parser/parser.rs, src/js_parser/p.rs
Adds can_be_binding_identifier plus replacement_export_ref and hoisted-binding checks for replacement aliases.
Export clause replacement logic
src/js_parser/visit/visit_stmt.rs
Keys export-clause replacement lookup by exported alias and injects replacements only when a usable ref is returned.
Re-export replacement logic
src/js_parser/visit/visit_stmt.rs
Keys re-export replacement lookup by exported alias and falls back to normal import handling when no replacement ref is produced.
Class binding-name validation
src/js_parser/lower/lower_decorators.rs
Switches class-expression binding-name validation to can_be_binding_identifier.
Docs and transpiler coverage
packages/bun-types/bun.d.ts, test/bundler/transpiler/transpiler.test.js, test/integration/bun-types/fixture/transpiler.ts
Updates exported-name docs and type shapes, and adds transpiler/type tests for renamed exports, reserved words, collisions, and invalid keys.

Related PRs: None identified

Suggested labels: bug, javascript, javascript-transpiler

Suggested reviewers: Jarred-Sumner, paperclover

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely summarizes the main change: matching exports.eliminate/replace on exported names.
Description check ✅ Passed The description is thorough and includes both what changed and verification details, though it uses custom headings instead of the template.

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Jul 5, 2026
@robobun

robobun commented Jul 5, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:05 PM PT - Jul 5th, 2026

❌ @robobun, your commit 16e091c has 1 failures in Build #68671 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33386

That installs a local version of the PR into your bun-33386 executable, so you can run:

bun-33386 --bun

@robobun

robobun commented Jul 5, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on main with Bun.Transpiler directly, no fixture needed:

new Bun.Transpiler({ loader: "ts", exports: { eliminate: ["QA"] } })
  .transformSync(`const q = 1; export { q as QA };`);
// "QA" survives, because the clause matched the local "q"

export { rr as RR } from "./dep" in the same build matches the exported name, so the two clause forms disagreed and no single name worked for both.

Verified

  • test/bundler/transpiler/transpiler.test.js: 38 new tests. The re-export replacement cases assert (r#ref.is_symbol()) on any debug build without this PR, because s_export_from was handing inject_replacement_export a parse-time name ref where a symbol ref was expected. Indexing the symbol table by a name ref is what segfaults released bun once several such clauses run in one process.
  • test/bundler/transpiler/ + bundler_decorator_metadata + bundler_esm + bundler_edgecase: 4317 pass, 0 fail. The decorator suites cover the lower_decorators.rs dedup.
  • test/integration/bun-types/bun-types.test.ts: 12 pass, 0 fail, and it fails on the old Record<string, string> annotation, so the new fixture is not vacuous.
  • cargo clippy -p bun_js_parser, cargo fmt --check and prettier are clean.

Only Bun.Transpiler populates replace_exports; the bundler always passes an empty map, so no other code path changes behavior.

CI: the diff is green, two unrelated lanes are red

Build 68671 is the first to finish rather than be superseded: 284 jobs passed, 2 failed, and neither failure is reachable from this diff.

1. :darwin: 26 aarch64 - test-bun — buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'. No test ran (16 KB log). darwin-aarch64-build-bun passes every time, so the binary builds from this branch fine; the shard just cannot pull it down. This has taken 5 of the 6 builds:

build
68607 artifact timeout, 2 shards, no test ran
68630 got the artifact: ran all 2511 files, every one 0 fail, then exited 2 at teardown
68642 artifact timeout, no test ran
68651 artifact timeout, no test ran
68659 artifact timeout, no test ran
68671 artifact timeout, no test ran

2. :darwin: 14 x64 - test-bun — test/js/bun/terminal/terminal.test.ts, Bun.spawn with terminal option > creates subprocess with terminal attached, timed out after 90000 ms (88 pass, 1 fail in that file). A PTY spawn test, and it is not this diff:

  • Both shards of that lane passed on 68630, which already carried the parser fix and its guards.
  • The file passes locally against this branch in 6.25 s: 89 pass, 1 todo, 0 fail.
  • Every call site of replacement_export_ref sits inside if p.options.features.replace_exports.count() > 0, and only Bun.Transpiler ever populates that map, so none of the parser code here executes while a PTY test spawns subprocesses.

I have not pushed a ci: retrigger. Across six builds the artifact download has failed five times, so a re-roll is a worse than even bet on several hours of CI, and it cannot fix an S3 download either way. My Buildkite token is read-only, so I cannot retry the two jobs; anyone with write access can.

Comment thread src/js_parser/visit/visit_stmt.rs Outdated
exports.replace keys are validated with is_identifier, which accepts every
keyword, so `replace: { default: 9 }` constructs fine. A renamed export then
reached declare_symbol with the alias and printed `export var default = 9`,
which is a syntax error. The same holds for `class`, `let`, `eval` and the
rest.

Only take the rename path when the alias can spell a strict-mode binding.
Reserved-word aliases keep their export clause, which is what main already
did for `export { q as default }`.

eliminate is unaffected: it deletes the clause item and never declares a
binding, so `export { q as default }` and `export { q as class }` are still
removed by their exported name.
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch, and the analysis is right: is_identifier is a pure ID_Start/ID_Continue check, so it accepts every keyword and replace: { default: 9 } constructs without complaint. I had convinced myself the key validation made a reserved-word alias unreachable, and it doesn't. Fixed in 4901f1c.

Verified the claim before fixing, with the released binary (i.e. main):

input, with replace: { default: 9 } main
export default 1 export default 9;
export { q as default } unchanged (keyed on the local q)
export { rr as default } from "./d" export var rr = 9;
export { default } from "./d" panics
export * as default from "./d" export var default = 9; (invalid)
replace: { foo: ["default", true] } export var default = true; (invalid)

So the two clause forms this PR rewires would indeed have started printing export var default = ....

What changed

Rather than guard only default, the rename path now requires the alias to be spellable as a strict-mode binding, since that is the actual precondition for emitting export var <alias> = value:

fn can_bind_exported_name(name: &[u8]) -> bool {
    js_ast::lexer_tables::keyword(name).is_none()
        && !js_lexer::is_strict_mode_reserved_word(name)
        && name != b"eval"
        && name != b"arguments"
}

keyword() covers default/class/for/..., and the other two clauses cover let/yield/static (which declare_symbol would have turned into a strict-mode error) and eval/arguments (which is_identifier and declare_symbol both wave through, but var eval = 1 is a SyntaxError in a module). When the guard fails the clause item is kept, which is what main does for export { q as default } today.

eliminate is untouched by the guard: Delete drops the clause item and never declares a binding, so export { q as default } and export { q as class } are still removed by their exported name. That is the bug this PR is actually about, and it stays fixed.

Every form the PR touches now produces parseable output, checked by re-transpiling each result:

clause as default + replace    => "var q = 1;\n\nexport { q as default };"   ok
clause as class + replace      => "var q = 1;\n\nexport { q as class };"     ok
clause as let + replace        => "var q = 1;\n\nexport { q as let };"       ok
clause as eval + replace       => "var q = 1;\n\nexport { q as eval };"      ok
reexport as default + replace  => "export { rr as default } from \"./d\";"   ok

Six more tests cover this (default, class, let, eval, the renamed re-export, and eliminate on a reserved word). The suite is now 17 tests, 9 of which fail on main; test/bundler/transpiler/ + bundler_esm + bundler_edgecase stay green at 4294 passing.

Deliberately left for a follow-up

Routing the replacement to S::ExportDefault when the exported name is default is the better end state, and it would close more than this PR opened: the pre-existing export * as default and Inject-named-default invalid output, plus the export { default } from "./d" panic above. I left it out because it is a behavior addition rather than a regression fix, it wants its own tests, and inject_replacement_export's Inject arm has a twin in replace_decl_and_possibly_remove that mutates a decl in place and cannot push a statement, so a complete job means restructuring that path too. Happy to do it as a separate PR.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js_parser/visit/visit_stmt.rs`:
- Around line 196-221: The rename/bindability handling duplicated in
s_export_clause and s_export_from should be extracted into a shared helper
instead of repeated multi-line logic. Create a small named helper around the
common pattern in visit_stmt.rs that computes rename, checks
can_bind_exported_name, chooses between declare_symbol(Kind::Hoisted, alias_loc,
alias) and the original ref, and then calls inject_replacement_export. Update
both call sites to use that helper so the replacement export behavior stays
identical in both paths.

In `@test/bundler/transpiler/transpiler.test.js`:
- Around line 1601-1605: Add a test case alongside the existing s_export_from
rename coverage in transpiler.test.js to exercise export { rr as RR } from
"./dep" with replace using the Inject array form, mirroring the s_export_clause
matrix case. Keep the same test style as the existing transform assertions, and
verify the renamed re-export path in s_export_from correctly handles replace: {
RR: ["INJ", true] } so the bindability/rename branch is covered for both scalar
and array-form replacements.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0f6ad47c-1436-40df-b0c1-5fbe4e74fb2d

📥 Commits

Reviewing files that changed from the base of the PR and between fb50cce and 4901f1c.

📒 Files selected for processing (3)
  • packages/bun-types/bun.d.ts
  • src/js_parser/visit/visit_stmt.rs
  • test/bundler/transpiler/transpiler.test.js

Comment thread src/js_parser/visit/visit_stmt.rs
Comment thread test/bundler/transpiler/transpiler.test.js
s_export_clause and s_export_from resolved the ref to bind identically:
compute whether the export is renamed, gate on the alias being bindable,
then declare a hoisted symbol for the alias or reuse the local ref.

Move it next to inject_replacement_export as replacement_export_ref, which
returns None when the alias has no binding spelling so the caller keeps the
clause item. can_bind_exported_name moves alongside it.

Also cover the renamed re-export with the inject array form, mirroring the
export-clause case.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/js_parser/visit/visit_stmt.rs:210-215 — The Kind::Hoisted merge covers a pre-existing var of the alias name, but not const/let/class: const QA = 5; const q = 1; export { q as QA }; under replace: { QA: 9 } now throws a spurious "QA" has already been declared (pointing at the alias inside the export clause, which declares nothing). Pre-PR this input was a silent no-match, so it's a valid-input → hard-error regression, though on a very contrived intersection. It'd be consistent with the reserved-word policy to fall through and leave the export alone when the alias already has a lexical binding (checking p.current_scope().members before declare_symbol), rather than letting declare_symbol log the error — same applies to the s_export_from path at :324-329.

    Extended reasoning...

    What the bug is. When exports.replace matches a renamed export clause and the alias happens to collide with an existing lexical binding in the module (const/let/class), the new declare_symbol(Kind::Hoisted, alias_loc, alias) call fails the merge and logs "<alias>" has already been declared. transformSync then throws on syntactically valid input. The PR explicitly chose Kind::Hoisted so a pre-existing var <alias> merges (and tests it at transpiler.test.js:1610), but that only covers hoisted siblings — a lexical sibling still hits SymbolMergeResult::Forbidden.

    Code path. In visit_stmt.rs:210-215 (and the mirrored s_export_from block at :324-329), when rename is true and can_bind_exported_name(alias) is true, the code calls p.declare_symbol(js_ast::symbol::Kind::Hoisted, items[i].alias_loc, alias). At visit time the module scope already contains the parse-phase declaration of const QA (kind Constant). declare_symbol (p.rs:4911-4933) finds the existing member, calls can_merge_symbol_kinds::<true>(Entry, Constant, Hoisted), and walks scope.rs:153-238: existing != Unbound; not the TS-import branch; the hoisted-or-function branch at :200-208 requires both kinds to satisfy is_hoisted_or_function (symbol.rs:391-395: only Hoisted | HoistedFunction | GeneratorOrAsyncFunction), and Constant is not — so it falls through to Forbidden at :238. The Forbidden arm calls log().add_symbol_already_declared_error(source, alias, alias_loc, existing.loc) and returns Ok(existing.ref_), so the ? doesn't short-circuit; the error surfaces later when transformSync checks log.errors > 0 and throws.

    Why nothing prevents it. can_bind_exported_name only checks whether the alias is a keyword / strict-mode reserved word / eval / arguments; it cannot know about existing scope members. And declare_symbol's Forbidden branch is designed to log (not propagate via ?), so the caller has no way to observe the collision and fall through instead.

    Impact. Pre-PR, replace: { QA: 9 } on const QA = 5; const q = 1; export { q as QA }; keyed on the local q, never matched, and passed through unchanged. Post-PR the same input hard-errors. The diagnostic is also misleading: it points at the QA inside export { q as QA }, which does not declare anything in the user's source. This is inconsistent with the policy this PR itself introduces for the analogous "cannot emit export var <alias>" case (reserved-word aliases), which falls through and leaves the export alone. It's also inconsistent with the unrenamed path: const foo = 1; export { foo }; + replace: { foo: 9 } reuses ref_ without redeclaring, so no error there. That said, the input is highly contrived (niche Bun.Transpiler API × renamed export × unrelated lexical binding whose name equals the alias), pre-PR behavior was also wrong (silent miss), and there is no obviously-correct output — export var QA = 9 alongside const QA = 5 would itself be a SyntaxError — so erroring is arguably no worse than emitting broken JS; only the message is bad.

    Step-by-step proof.

    1. new Bun.Transpiler({ loader: "ts", exports: { replace: { QA: 9 } } }).transformSync("const QA = 5; const q = 1; export { q as QA };").
    2. Parse: const QA = 5 → parse_and_declare_decls(symbol::Kind::Constant, ...) (parse_stmt.rs:180) puts QA: Constant in the module scope.
    3. Visit export { q as QA }: alias = b"QA", name = b"q"; replace_exports.get_ptr(b"QA") matches; entry.is_replace() && alias != name → rename = true; can_bind_exported_name(b"QA") → true (not a keyword/reserved/eval/arguments).
    4. p.declare_symbol(Kind::Hoisted, alias_loc, b"QA") → existing member found → can_merge_symbol_kinds(Entry, Constant, Hoisted) → Forbidden → logs add_symbol_already_declared_error("QA", alias_loc, <const QA loc>), returns Ok(existing.ref_).
    5. transformSync sees log.errors > 0 and throws "QA" has already been declared.
      Same for let QA (Kind::Other) / class QA {} (Kind::Class), and for const QA = 5; export { rr as QA } from "./d"; via the s_export_from branch.

    Fix. Before calling declare_symbol, look up alias in p.current_scope().members; if it exists with a non-hoisted-or-function kind, fall through and leave the export clause alone (same policy as the reserved-word case). Alternatively, declare a fresh generated symbol and emit export { <tmp> as <alias> } instead of export var <alias>, which sidesteps both this collision and the reserved-word case in one go.

  • 🟡 src/js_parser/visit/visit_stmt.rs:322-332 — The can_bind_exported_name guard added in 4901f1c only fires when rename is true, so it misses the unrenamed re-export spelling: export { default } from "./d" under replace: { default: 9 } has alias == original_name, rename = false, and still emits export var default = 9;. Output is byte-identical to pre-PR (not a regression), but the PR now tests that export { rr as default } from is left alone while the semantically-equivalent unrenamed form is not — in s_export_from the guard should gate on can_bind_exported_name(alias) whenever entry.is_replace(), keeping rename only for the declare_symbol decision.

    Extended reasoning...

    What the bug is. Commit 4901f1c added can_bind_exported_name and applied it in both clause forms behind the condition !rename || can_bind_exported_name(alias), where rename = entry.is_replace() && alias != original_name. In s_export_from, an unrenamed re-export of a keyword is legal syntax — export { default } from "./d" — and there alias == original_name == b"default", so rename = false, the || short-circuits without ever consulting can_bind_exported_name, export_ref = old_ref (whose stored name is default), and inject_replacement_export still emits export var default = 9;, a SyntaxError.

    Step-by-step.

    1. parse_export_clause on export { default } from "./d" sets alias = b"default" and, with no as, original_name = alias (parse_import_export.rs:301). name.ref_ = store_name_in_ref(b"default"). The keyword check at ~448 only fires when from is absent, so this parses as S::ExportFrom.
    2. In s_export_from (visit_stmt.rs:322), entry.is_replace() is true for replace: { default: 9 }, and alias != items[i].original_name.slice() is b"default" != b"default" → false, so rename = false.
    3. !rename || can_bind_exported_name(alias) → true || … → true. The guard is bypassed.
    4. export_ref = old_ref (name default), and inject_replacement_export's Replace arm builds S::Local { is_export: true, decls: [B::Identifier(old_ref) = 9] } → prints export var default = 9;.

    Same for export { class } from "./d", export { if } from "./d", etc.

    Why nothing catches it. The guard was written to protect the newly introduced declare_symbol(Hoisted, …, alias) path, which only runs when alias != original_name. But in s_export_from the old_ref's stored name is the keyword too (it came from store_name_in_ref(alias)), so the unrenamed branch has the same emission problem the guard was added to prevent. s_export_clause cannot hit this — export { default } without from is a parse error, so the local name there is always a valid binding — which is why the gap is s_export_from-specific.

    Impact / severity. Pre-existing: the pre-PR code passed old_ref unconditionally on this exact path, so output for export { default } from "./d" under replace: { default: 9 } is byte-for-byte the same before and after this PR. Filing as a nit rather than pre_existing only because 4901f1c added can_bind_exported_name in this PR specifically for this defect class in response to review, and the PR now tests that export { rr as default } from "./d" is left alone — leaving the unrenamed spelling of the same re-export inconsistent with the tested renamed spelling. Extremely niche trigger (undocumented API × reserved-word key × specific spelling), so not worth blocking merge over.

    Fix. In s_export_from, decouple the guard from rename: gate on !entry.is_replace() || can_bind_exported_name(alias), and keep rename only for choosing between declare_symbol and old_ref. s_export_clause needs no change (the unrenamed case there cannot carry a keyword). Optionally add export { default } from "./d" alongside the existing "leaves a renamed re-export of a reserved word alone when replacing" test.

  • 🟡 src/js_parser/visit/visit_stmt.rs:40-45 — can_bind_exported_name misses await: it is neither in KEYWORDS nor the strict-mode set, but in a module (which any source containing export {…} is) it is a reserved BindingIdentifier — so replace: { await: 9 } on export { q as await } now emits export var await = 9;, a SyntaxError. The near-identical sibling can_be_class_binding_name (src/js_parser/lower/lower_decorators.rs:134-140) already carries the explicit && name != b"await" clause; add the same here and drop "await" into the it.each(["default", "class", "let", "eval"]) list.

    Extended reasoning...

    What happens. Commit 4901f1c added can_bind_exported_name to stop Replace from emitting export var <keyword> = … when the alias is a reserved word. It checks keyword(), is_strict_mode_reserved_word(), eval, and arguments. await passes all four: it has no entry in js_ast::lexer_tables::KEYWORDS (there is no TAwait token variant — await is lexed contextually), it is not one of the nine strict-mode reserved words (implements, interface, let, package, private, protected, public, static, yield), and it is neither eval nor arguments. So can_bind_exported_name(b"await") returns true.

    Why that is wrong here. The doc comment on the function asks "can name spell the binding of an export var <name> = ... in a module". Per ES2024 §13.1.1, a BindingIdentifier whose StringValue is "await" is an early SyntaxError when the goal symbol is Module. Any source that reaches this branch contains an export { … } clause and is therefore a Module, so export var await = 9; is rejected by every engine.

    Step-by-step.

    1. new Bun.Transpiler({ loader: "ts", exports: { replace: { await: 9 } } }) — key "await" passes JSLexer::is_identifier, entry stored.
    2. Input const q = 1; export { q as await }; reaches the s_export_clause arm; alias = b"await", name = b"q".
    3. replace_exports.get_ptr(alias) matches; rename = true (is_replace() && alias != name).
    4. can_bind_exported_name(b"await"): keyword(b"await").is_none() → true; !is_strict_mode_reserved_word(b"await") → true; != b"eval" → true; != b"arguments" → true. Result: true.
    5. declare_symbol(Kind::Hoisted, loc, b"await") succeeds (its guard at p.rs:4877-4879 only checks the strict-mode subset).
    6. inject_replacement_export emits S::Local { is_export: true, kind: Var, decls: [B::Identifier(await_ref) = 9] }; NoOpRenamer prints export var await = 9;.

    The same applies to the s_export_from branch for export { rr as await } from "./d".

    Regression shape. Pre-PR, export { q as await } under replace: { await: 9 } did not match at all (keyed on local q), so the output stayed valid. Post-PR it matches and prints a SyntaxError. For the export { rr as await } from "./d" form, pre-PR emitted export var rr = 9; (wrong exported name, but syntactically valid); post-PR emits export var await = 9; (syntax error). So on both paths this moves from "wrong-but-parses" to "does-not-parse".

    Not a duplicate of the earlier comment. The existing inline comment on this file flagged the absence of any reserved-word guard (leading with default); 4901f1c added can_bind_exported_name in response and the it.each(["default", "class", "let", "eval"]) test to cover it. This is a residual gap in that new guard — the one contextual reserved word that falls between the keyword() table and the strict-mode set.

    Fix. One line: add && name != b"await" to can_bind_exported_name, matching the sibling can_be_class_binding_name at src/js_parser/lower/lower_decorators.rs:134-140 (whose doc comment names "await" explicitly). Adding "await" to the it.each list at test/bundler/transpiler/transpiler.test.js covers it. Alternatively, share one helper between the two files — per CLAUDE.md "grep for the in-tree helper before hand-writing anything" — since they now differ only by the is_identifier prefix check.

    Severity: nit. The trigger is the intersection of a niche API (Bun.Transpiler exports.replace), a replace key of exactly "await", and the export { x as await } spelling — vanishingly rare in real code. But the fix is one line the codebase already has elsewhere, and the guard was added in this PR precisely to prevent this class of syntax-error output, so it is worth closing the gap here rather than in a follow-up.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js_parser/p.rs`:
- Around line 6262-6266: The alias reuse in the export lowering path currently
lets alias == local_name return local_ref before checking
can_bind_exported_name, which can produce invalid output for Replace aliases.
Update the logic in the export resolution flow around the local_ref fast-path so
bindability is checked first, and only reuse local_ref when the alias is valid
to bind; keep the same behavior in the surrounding replacement handling and
can_bind_exported_name branch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9c56e36a-7bf7-4b26-9902-3616ba9aad71

📥 Commits

Reviewing files that changed from the base of the PR and between 4901f1c and ddd73c6.

📒 Files selected for processing (3)
  • src/js_parser/p.rs
  • src/js_parser/visit/visit_stmt.rs
  • test/bundler/transpiler/transpiler.test.js

Comment thread src/js_parser/p.rs Outdated
can_bind_exported_name was a hand-rolled copy of can_be_class_binding_name
in lower_decorators.rs, minus its `await` clause. `await` is lexed
contextually, so it is in neither the keyword table nor the strict-mode set,
and `export { q as await }` under replace: { await: 9 } printed
`export var await = 9`, a syntax error in a module. Share the one predicate
as can_be_binding_identifier and delete the copy.

Check it before the `alias == local_name` fast path, not after. An unrenamed
re-export carries the exported name too, so `export { default } from "./d"`
under replace: { default: 9 } skipped the guard and asserted in
inject_replacement_export on a ref that is not a symbol.

The injected `export var <alias>` merges with a `var` of the same name but
not with a lexical one, so `const QA = 5; var q = 1; export { q as QA }`
reported a redeclaration the source never wrote. Ask can_merge_symbol_kinds
first and leave the clause alone when declare_symbol would refuse, matching
the reserved-word policy.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/bundler/transpiler/transpiler.test.js`:
- Around line 1636-1657: The reserved-word coverage for re-exports is incomplete
compared with export clauses. Update the `it.each` cases in `transpiler.test.js`
around the `transform` assertions for `export { rr as %s } from "./d";` and
`export { default } from "./d";` to exercise the same reserved-word matrix as
the `export { q as %s }` test, using the shared bindability behavior in the
transpiler paths for `s_export_clause` and `s_export_from`.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b0558b50-ff8b-4965-bce9-5204782787f0

📥 Commits

Reviewing files that changed from the base of the PR and between ddd73c6 and 6017970.

📒 Files selected for processing (4)
  • src/js_parser/lower/lower_decorators.rs
  • src/js_parser/p.rs
  • src/js_parser/parser.rs
  • test/bundler/transpiler/transpiler.test.js

Comment thread test/bundler/transpiler/transpiler.test.js

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/js_parser/p.rs:9682-9687 — can_bind_exported_name misses await: it is in neither the keyword() table nor the strict-mode reserved-word set, yet the ES spec makes it a BindingIdentifier early error when the goal symbol is Module. So replace: { await: 9 } on export { q as await } (or export { rr as await } from "./d") passes the guard and prints export var await = 9;, which is unparseable in a module. The sibling can_be_class_binding_name in src/js_parser/lower/lower_decorators.rs:134-140 solves the identical problem and already carries && name != b"await" — add the same clause here (and "await" to the it.each at transpiler.test.js:1624).

    Extended reasoning...

    What the bug is. can_bind_exported_name (src/js_parser/p.rs:9682-9687) is the guard 4901f1c added to stop replace: { <reserved>: ... } from emitting export var <reserved> = .... It checks keyword(name).is_none(), !is_strict_mode_reserved_word(name), and name != b"eval" / b"arguments". await passes all four: it is not in the KEYWORDS table (src/ast/lexer_tables.rs:178-215 — await is a contextual keyword, not a lexer token), and STRICT_MODE_RESERVED_WORDS is exactly the nine ES §12.7.2 words (implements/interface/let/package/private/protected/public/static/yield). So can_bind_exported_name(b"await") returns true.

    Step-by-step trigger.

    1. new Bun.Transpiler({ loader: "ts", exports: { replace: { await: 9 } } }) — the key "await" passes JSLexer::is_identifier (pure ID_Start/ID_Continue), so the entry is stored.
    2. Input var q = 1; export { q as await }; reaches the s_export_clause arm; alias = b"await", name = b"q".
    3. replace_exports.get_ptr(alias) matches; replacement_export_ref sees is_replace() && alias != local_name, calls can_bind_exported_name(b"await") → true, then declare_symbol(Kind::Hoisted, ..., b"await") (which only guards the nine strict-mode words, so it succeeds).
    4. inject_replacement_export builds S::Local { is_export: true, kind: Var, decls: [B::Identifier(ref) = 9] }; NoOpRenamer prints the symbol's original name verbatim → export var await = 9;.
    5. Per the ES spec (13.1.1 Static Semantics: Early Errors — "It is a Syntax Error if the goal symbol of the syntactic grammar is Module and the StringValue of BindingIdentifier is "await""), that output is unparseable in a module.

    The s_export_from path is identical: export { rr as await } from "./d" under replace: { await: 9 } now emits export var await = 9; (pre-PR it emitted export var rr = 9; — wrong exported name, but syntactically valid).

    Why nothing catches it. The PR description says the rename path is gated on "not a keyword, not a strict-mode reserved word, not eval/arguments", and that is exactly what the code does — but await lives in neither lookup table. It is only reserved by the Module-goal early error, the same way yield is reserved only in strict mode. is_strict_mode_reserved_word picks up yield; nothing picks up await.

    In-tree precedent. can_be_class_binding_name at src/js_parser/lower/lower_decorators.rs:134-140 answers the identical question ("can this name be a strict-mode/module binding?") and, after the same keyword().is_none() && !is_strict_mode_reserved_word() checks, explicitly adds && name != b"await". Its doc comment (line 131) names await alongside default and let as a word that would turn the emitted binding into a syntax error.

    Newly reachable. Pre-PR, s_export_clause keyed on the local q, so replace: { await: 9 } on export { q as await } never matched and the input passed through unchanged. This PR makes it match, and 4901f1c's guard was meant to close exactly this class — await is the one case the two tables it consults do not cover. This is the same bug class as the default/class/let/eval cases already fixed and tested.

    Fix. Add && name != b"await" to can_bind_exported_name (or replace both helpers with one shared predicate), and add "await" to the it.each(["default", "class", "let", "eval"]) at test/bundler/transpiler/transpiler.test.js:1624.

Comment thread src/js_parser/p.rs
s_export_clause and s_export_from share the bindability check, so the
re-export cases should exercise the same keywords the export-clause cases
do, renamed and unrenamed.
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

All three of the findings above are real and are fixed in 6017970, and the last two claude[bot] comments (the await gap and the lexical collision) are re-reports against the commit before that fix. The can_declare_hoisted diff suggested in the collision comment is, byte for byte, what landed.

What changed

await. Exactly right: it is lexed contextually, so it sits in neither KEYWORDS nor the strict-mode set, and export { q as await } under replace: { await: 9 } printed export var await = 9;. The suggestion to collapse both helpers into one shared predicate is the one I took, since can_bind_exported_name was a hand-rolled near-copy of can_be_class_binding_name missing precisely that clause. parser.rs now owns it and lower_decorators.rs calls it:

pub(crate) fn can_be_binding_identifier(name: &[u8]) -> bool {
    js_lexer::is_identifier(name)
        && js_lexer::keyword(name).is_none()
        && !js_lexer::is_strict_mode_reserved_word(name)
        && name != b"await"
        && !is_eval_or_arguments(name)
}

The lexical collision. Kind::Hoisted merges with a var but not with const/let/class, so const QA = 5; var q = 1; export { q as QA } reported a redeclaration the source never wrote. replacement_export_ref now asks can_merge_symbol_kinds what declare_symbol would decide and returns None on Forbidden, so the clause is left alone.

The alias == local_name bypass (also caught independently by coderabbit). The guard now runs before the fast path. This one was worse than invalid output: old_ref in s_export_from is a parse-time name ref, not a symbol ref, so a debug build hit assertion failed: r#ref.is_symbol().

The crash this was hiding

Widening the re-export reserved-word matrix to the full five keywords (3e35644) turned up the real severity. Each input is individually harmless on released bun, just wrong:

export { rr as class } from "./d"  + replace: { class: 9 }  ->  export var rr = 9;      (wrong exported name)
export { class } from "./d"        + replace: { class: 9 }  ->  export var class = 9;   (unparseable)

Run back to back in one process, released bun segfaults:

panic(main thread): Segmentation fault at address 0x2C102400000

Same root cause: a name ref used where a symbol ref was expected corrupts the symbol table. With the guard those clauses are left untouched and the crash is gone.

Verification

The describe block is 30 tests and covers every branch of can_be_binding_identifier: the keyword table (default, class), the strict-mode set (let), the contextual await, and is_eval_or_arguments (eval), across renamed clauses, renamed re-exports, and unrenamed re-exports.

On released bun the suite does not merely fail, it takes the process down: 12 fail, 11 pass, then the segfault ends the run. On this branch transpiler.test.js is 223 tests, 0 fail, and test/bundler/transpiler/ + bundler_decorator_metadata + bundler_esm + bundler_edgecase are green at 4302 passing, which is what covers the lower_decorators.rs dedup.

Comment thread packages/bun-types/bun.d.ts Outdated
exports.replace was typed Record<string, string>, but JSTranspiler takes any
of string, number, boolean, null or undefined, plus a [name, value] pair that
exports the value under a different name. The transpiler tests already rely on
both, so a TypeScript caller writing the documented Next.js config
({ getStaticProps: ["__N_SSG", true] }) was told number is not assignable to
string.

Nothing type-checked a non-string replace value, so add a fixture that pins
the accepted space and rejects objects.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/bun-types/bun.d.ts (1)

2445-2454: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Clarify that replace keys must be ECMAScript identifiers packages/bun-types/bun.d.ts:2445

exports.replace rejects non-identifier keys at runtime, including the first element of [name, value]. Call out that this is narrower than eliminate, so string-named or reserved-word exports won’t work here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/bun-types/bun.d.ts` around lines 2445 - 2454, Clarify the `replace`
option in the export replacement docs: its keys in `ExportReplacement` are
validated as ECMAScript identifiers at runtime, including the first element of
any `[name, value]` pair, so update the `replace?: Record<string,
ExportReplacement | [string, ExportReplacement]>` comment near `replace` to
explicitly state this constraint and note that it is narrower than `eliminate`,
meaning string-named or reserved-word exports are not supported here.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/bun-types/bun.d.ts`:
- Around line 2445-2454: Clarify the `replace` option in the export replacement
docs: its keys in `ExportReplacement` are validated as ECMAScript identifiers at
runtime, including the first element of any `[name, value]` pair, so update the
`replace?: Record<string, ExportReplacement | [string, ExportReplacement]>`
comment near `replace` to explicitly state this constraint and note that it is
narrower than `eliminate`, meaning string-named or reserved-word exports are not
supported here.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c4ba89a7-7560-4884-9ec3-3a364c9a74bb

📥 Commits

Reviewing files that changed from the base of the PR and between 3e35644 and 1ba5047.

📒 Files selected for processing (2)
  • packages/bun-types/bun.d.ts
  • test/integration/bun-types/fixture/transpiler.ts

A replace key, and the name in a [name, value] pair, are validated as
ECMAScript identifiers, so a string-named export throws. eliminate has no
such restriction. A replacement is also emitted as `export var <name>`, which
a reserved word cannot spell, so { default: 1 } replaces `export default x`
but leaves `export { x as default }` alone.
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch, documented in a2d7d80. Verified both halves against the released binary first (the key validation is untouched by this PR, so it is a valid oracle):

replace:   { "a-b": 1 }        ->  THROW  "a-b" is not a valid ECMAScript identifier
replace:   { foo: ["a-b", 1] } ->  THROW  "a-b" is not a valid ECMAScript identifier   (the pair's name too)
eliminate: ["a-b"]             ->  OK

One nuance worth being precise about, since "reserved-word exports won't work here" is only half true. A reserved word is accepted as a key, because is_identifier is a pure ID_Start/ID_Continue check: replace: { default: 1 } constructs fine. It just has no export var spelling, so it lands differently depending on the export form, and export default x genuinely does work. So the JSDoc separates the two failure modes:

 * Keys, and the `name` of a pair, have to be valid ECMAScript
 * identifiers; anything else throws. That is narrower than `eliminate`,
 * so `export { q as "a-b" }` can only be eliminated. A replacement is
 * emitted as `export var <name>`, which a reserved word cannot spell:
 * `{ default: 1 }` replaces `export default x` but leaves
 * `export { x as default }` untouched.

and eliminate now says it takes any exported name, string-named ones included.

Every sentence there is backed by a test already in this PR: rejects a non-identifier exports.replace key, eliminates a string-named export clause, and the default row of the reserved-word matrix. The bun-types fixture also pins eliminate: ["a-b"] as type-valid, so the two options cannot silently converge.

test/integration/bun-types/bun-types.test.ts is 12 pass, 0 fail. Docs and a .d.ts only, so no native build.

Comment thread src/js_parser/p.rs Outdated
replacement_export_ref reused local_ref whenever the export was not renamed.
In s_export_from that ref is the parse-time name ref from store_name_in_ref,
never a symbol, so `export { RR } from "./dep"` under replace: { RR: 9 } sent
it to inject_replacement_export and tripped r#ref.is_symbol(), the same
assertion the reserved-word reordering fixed for `export { default } from`.

A re-export has no local binding, so Replace has to declare the alias itself.
Gate the fast path on local_ref.is_symbol() and let the rest fall through to
can_declare_hoisted, which also means a colliding lexical binding leaves the
clause alone exactly as it does when the export is renamed.
Comment thread src/js_parser/p.rs
can_declare_hoisted rejected only Forbidden merges, but TypeScript merges a
`var` into an import rather than refusing it, on the grounds that the import
may be type-only. Both still bind the name, so scan_imports rejects the file
once the import is used as a value: `import { QA } from "./d"; console.log(QA);
var q = 1; export { q as QA }` under replace: { QA: 9 } reported "QA" has
already been declared, where main passed the input through untouched.

Reject Kind::Import and leave the clause alone, as with const/let/class. Keep
the Forbidden check for everything else, since an Unbound name is not a
declaration and the injected `var` should still bind it.
Comment thread src/js_parser/p.rs
can_declare_hoisted sat behind the `alias == local_name` fast path, so it only
ran when the clause renamed the export. An unrenamed one reused the local ref
whatever its kind, and `const foo = 1; export { foo }` under replace: { foo: 9 }
printed `export var foo = 9` beside the const. `import { foo } from "./d";
export { foo }` did the same, which is the ordinary re-export-an-import shape.

Hoist the check above the fast path so it gates both forms. The collision rules
are now the same everywhere: `var` and function declarations merge, const/let/
class/import leave the clause alone, an unbound name binds.
@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-05, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant