Skip to content

js_parser: rewrite assignments to exports of sibling TypeScript namespace blocks - #38709

Open
robobun wants to merge 5 commits into
mainfrom
farm/c00a7e0d/ts-namespace-cross-block-assign
Open

robobun wants to merge 5 commits into
mainfrom
farm/c00a7e0d/ts-namespace-cross-block-assign

Conversation

@robobun

@robobun robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • In a TypeScript namespace split across blocks, an assignment to an export of another block prints as a bare identifier: namespace N { export let v = 1 } namespace N { export function set() { v = 99 } } emits v = 99, not N.v = 99. Reads already emit N.v. The result is ReferenceError: v is not defined or a write to an outer v.
  • With --minify the renamer can hand the unreserved bare name to the closure argument: ((x)=>(x=[],x.y=2))(e||={}) silently replaces the namespace object.
  • Cause: find_symbol (src/js_parser/scan/scan_symbols.rs:76) resolves the reference to a proxy symbol that only carries a namespace_alias, and handle_identifier (src/js_parser/p.rs:1531) applies the alias to reads only.

Fix

  • Also record the proxy in is_exported_inside_namespace. handle_identifier already uses that map, in every position, to print a block's own exports as N.v.
  • Reads are unchanged: the alias path returns first, so enum inlining and nested namespaces behave as before.
  • This matches tsc. esbuild, which this parser is ported from, emits the same bare write, so the divergence is deliberate.
  • Verified: six cases in test/bundler/transpiler/transpiler.test.js and two --minify cases in test/bundler/bundler_minify.test.ts fail on 1.4 and pass here.

Background

  • Bun lowers each namespace N { ... } block to ((N) => { ... })(N ||= {}). An export becomes N.v = 1, and is_exported_inside_namespace maps its symbol to the closure argument so references print as N.v.
  • Same-named blocks share one exported-member map. A reference to such a member from another block gets a proxy symbol whose namespace_alias means "<closure arg>.<name>".
  • namespace_alias also represents an import rewritten to a property access. An assigned-to import must stay a bare identifier, so handle_identifier skips the alias for assignment targets.
Notes
  • Write forms covered by the transpiler tests: =, ++, +=, ??=, array and object destructuring targets (including renamed and nested), for (v of ...), for (v in ...), reassigning an exported function, a write to an export declared in a later block, a write from a nested namespace to the outer namespace's export, and a check that a local let of the same name still shadows the export. A spawned runtime case checks that the outer variable is left alone.
  • The minify tests run the bundled and the non-bundled --minify output of the fuzz input (namespace N { export let x: any = 1 } namespace N { x = []; export const y = 2 } plus an enum that biases the name alphabet toward x) and expect {"x":[],"y":2} 1. On 1.4 both print {"x":1} 1; 1.3 threw a ReferenceError instead, so the silent form is a 1.4 regression of the same bug.
  • esbuild 0.25.12 was checked directly: same control flow, same bare v = 99 output. tsc 5.9 emits N.v for every read and write form.
  • A larger probe (all write forms, delete, nested namespaces, a namespace that exports a member with its own name, local shadowing) produces the same output as tsc's emitted JS under node when run through bun run, bun build, bun build --minify and bun --hot.
  • test/bundler/transpiler/transpiler.test.js, test/bundler/esbuild/ts.test.ts, default.test.ts, dce.test.ts, extra.test.ts, bundler_edgecase.test.ts, bundler_regressions.test.ts and bundler_minify.test.ts pass with the change.
  • Self-review: the one automated review concern (comment length in scan_symbols.rs) was addressed in 62adf4d.

…pace blocks

When a merged namespace block references an export declared in a sibling
block, find_symbol hands back a proxy symbol carrying a namespace alias.
handle_identifier only substitutes that alias for reads, so assignments
(`v = 1`, `v++`, `v += 1`, destructuring and for-in/of targets) printed
the proxy's bare name, which either threw a ReferenceError or wrote to an
unrelated outer variable with the same name. Reads were already emitted
as `ns.v`.

Register the proxy in is_exported_inside_namespace as well, so writes go
through the same `ns.name` rewrite that exports declared in the current
block already use. This matches what tsc emits.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 13 days. After that, they cost $0.25 per reviewed file.

Or wait 23 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 042e9718-81c7-4d18-b70b-c177633e422b

📥 Commits

Reviewing files that changed from the base of the PR and between b52d3e5 and 34e2391.

📒 Files selected for processing (3)
  • src/js_parser/scan/scan_symbols.rs
  • test/bundler/bundler_minify.test.ts
  • test/bundler/transpiler/transpiler.test.js

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

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The diff is green; the remaining CI red is unrelated to this change.

  • Build 112570 (current head 34e2391, main merged in) finished with 180 of 181 jobs passed. The one failed job is the debian 13 x64 ASAN shard, where test/js/node/test/parallel/test-crypto-dh-leak.js (a node:crypto Diffie-Hellman RSS check) exceeded its RSS bound; it fails the same way on main, does not touch the transpiler, and has been reported separately. The transpiler and bundler suites, including the eight new tests, passed on every lane.
  • Earlier runs: build 96907 (same src/ diff) passed 178 of 179 with an unrelated ASAN leak-test timeout; build 112523 was red only because the branch was behind main (expired localhost cert fixture, renewed in test: renew the expired localhost cert fixture #40488, and the binary-size comparison against current main), which the merge resolved.
  • The one review thread (comment length) is addressed and resolved.

Reproduced on 1.4.0 with bun build --no-bundle on:

let v = "outer";
namespace N { export let v = 1 }
namespace N {
  export function set() { v = 99 as any }
  export function inc() { v++ }
  export function rd() { return v }
}
N.set(); N.inc();
console.log(JSON.stringify({ "N.v": N.v, rd: N.rd(), outer: v }));

Before: set/inc print v = 99; / v++; (only rd becomes N.v), and the script logs {"N.v":1,"rd":1,"outer":100}. With this change every form prints N.v, and the script logs {"N.v":100,"rd":100,"outer":"outer"}, which is what tsc's output produces. With --minify, 1.4 prints {"x":1} 1 for namespace N { export let x: any = 1 } namespace N { x = []; export const y = 2 } because the renamer hands the unreserved bare name to the closure argument; the branch prints {"x":[],"y":2} 1.

Tests: six new cases under "generated closures" in test/bundler/transpiler/transpiler.test.js and two --minify cases in test/bundler/bundler_minify.test.ts; all fail on 1.4.3 and pass with this branch.

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

LGTM — one-line fix routing sibling-namespace proxy symbols through the existing is_exported_inside_namespace rewrite so writes emit ns.name like reads already do.

What was reviewed:

  • Traced handle_identifier in src/js_parser/p.rs: the namespace-alias path (reads, enum inlining, nested-namespace resolution) still returns first at :1531/:1589, so read behavior is unchanged; writes now fall through to the is_exported_inside_namespace lookup at :1644, which is the same path the current block's own exports already use.
  • Confirmed arg_ref is the enclosing block's closure argument (in scope at the reference site), matching how same-block exports are rewritten.
  • Tests cover the write-form matrix, forward references, nesting, local shadowing, and a runtime check — nothing vacuous.
Extended reasoning...

Overview

This PR fixes TypeScript namespace lowering: when a namespace is split across multiple namespace N { ... } blocks and one block assigns to an export declared in a sibling block, Bun previously emitted the assignment as a bare identifier (v = 99) instead of a property access on the closure argument (N.v = 99). Reads were already correct. The fix is a single line in src/js_parser/scan/scan_symbols.rs: when find_symbol creates the per-block proxy symbol for a sibling export, it now also records that proxy in is_exported_inside_namespace (proxy ref → closure-arg ref), which is the map handle_identifier already consults — unconditionally on assignment target — to rewrite same-block exports.

Security risks

None. This is a TypeScript-only transpiler code path (gated behind IS_TYPESCRIPT_ENABLED), affects only how identifiers inside merged namespace blocks are printed, and touches no auth/crypto/permissions/filesystem code.

Level of scrutiny

Medium. The parser is a hot, critical component, but this change is extremely narrow: it only fires when (a) TypeScript is enabled, (b) the reference is inside a namespace block, (c) the name is not a member of the current scope, and (d) the name IS in the shared exported_members map of a sibling block. The fix reuses an existing, well-exercised rewrite mechanism (is_exported_inside_namespace) rather than adding new logic. I traced handle_identifier (p.rs:1500–1686) and confirmed: for non-assignment-target reads, the namespace_alias branch at :1531 still fires first and returns via EImportIdentifier / enum-inline / nested-namespace at :1589/:1557/:1582, so enum member inlining and nested-namespace resolution are preserved; for assignment targets and delete, the alias branch is skipped, is_import_item and ref_to_ts_namespace_member don't contain the fresh proxy ref, and the new map entry at :1644 produces the ns.name E::Dot — exactly the path the current block's own exports already take.

Other factors

  • Six new tests in transpiler.test.js cover every write form (=, ++, +=, ??=, array/object destructuring targets, for-of/for-in), reassigning an exported function, an export declared in a later block, a write from a nested inner namespace, local shadowing (a same-named let in the block still shadows), and a spawned runtime check that the outer variable is untouched. The tests assert exact printed output and follow harness conventions (bunExe/bunEnv, drain both pipes concurrently, exit-code asserted last).
  • The PR intentionally diverges from esbuild 0.25.12 (which shares Bun's old bug) to match tsc, which is the correct reference for TypeScript-namespace semantics — the prior behavior was a ReferenceError or a silent write to the wrong variable, so there's no compatibility argument for keeping it.
  • No CODEOWNERS entry covers src/js_parser/. No prior human review comments to address. The bug-hunting system found nothing.

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:30 AM PT - Sep 8th, 2026

❌ @robobun, your commit 34e2391 has 1 failures in Build #112570 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 38709

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

bun-38709 --bun

Comment thread src/js_parser/scan/scan_symbols.rs Outdated

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

I reviewed this and didn't find any bugs — the fix is a one-line map insert that routes assignment targets through the same is_exported_inside_namespace path already used for a block's own exports, and reads are provably unchanged (the alias branch at p.rs:1531 returns first). Deferring only because it deliberately diverges from esbuild to follow tsc; a maintainer should confirm that call.

What was reviewed:

  • Traced handle_identifier in src/js_parser/p.rs: alias substitution is gated on non-assignment targets, the new map entry is only reached for writes/deletes, and the proxy's Kind::Other avoids the "Cannot assign to import" bundling error.
  • Confirmed arg_ref is the per-block closure argument, so the nested-namespace and later-block cases resolve to the right parameter.
  • Test coverage spans every write form, shadowing, forward reference, nested namespace, and a runtime check — matches tsc output.
Extended reasoning...

Overview

Two-line source change in src/js_parser/scan/scan_symbols.rs (find_symbol): when a lazily-created proxy symbol is minted for a reference to an export declared in a sibling TypeScript namespace block, also record it in is_exported_inside_namespace (proxy → closure arg). handle_identifier already consults that map without an assign-target guard (p.rs:1644), so writes now emit ns.x instead of a bare x. 168 lines of new tests in test/bundler/transpiler/transpiler.test.js cover =, ++, compound/logical assignment, destructuring targets, for-of/for-in, exported functions, forward references, nested namespaces, local shadowing, and a spawned runtime check.

Security risks

None. Pure TypeScript-lowering codegen; no untrusted-input parsing changes, no I/O, no allocation changes.

Level of scrutiny

Moderate-to-high — find_symbol is a hot parser path that runs on every identifier lookup. The change itself is mechanically trivial (one HashMap insert on a cold TypeScript-only branch, behind Self::IS_TYPESCRIPT_ENABLED), and I verified reads still short-circuit through the namespace_alias branch at p.rs:1531-1589 before reaching the new entry, so enum inlining and nested-namespace resolution are untouched. The proxy symbol is Kind::Other, so the "Cannot assign to import" bundling error at p.rs:1514 correctly does not fire.

Other factors

The PR explicitly diverges from esbuild 0.25.12 (which shares the bug) to follow tsc. REVIEW.md treats esbuild as the reference for ported parser code, so a maintainer should sign off on that deliberate divergence even though following the TypeScript compiler for TypeScript-specific lowering seems clearly correct here. The comment-cop review thread is resolved (comment shortened in 62adf4d). Test coverage is thorough and the PR body demonstrates the six new tests fail on release and pass on the branch.

With --minify-identifiers the bare identifier that was printed for an
assignment to a sibling namespace block's export is not reserved by the
renamer, so the namespace closure argument can receive the same name and
`x = []` silently replaces the namespace object instead of throwing. Add a
bundled and a non-bundled minify case that runs the output.

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

Code review found no issues

No high-confidence issues detected in this change.

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

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

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

2 participants