Skip to content

js_parser: bind import X = A.B to the enum or namespace it names - #47708

Draft
robobun wants to merge 4 commits into
mainfrom
robobun/51fdf501/enum-import-equals-alias
Draft

robobun wants to merge 4 commits into
mainfrom
robobun/51fdf501/enum-import-equals-alias

Conversation

@robobun

@robobun robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Parked: it needs a maintainer's answer.

Per-file tsc gives 3 4 on the seven reported shapes. esbuild and bun give 3 undefined. No user reported them. May the transpiler differ from esbuild for enum values?

  1. Yes, by this alias fold.
  2. Yes, by a run-time successor (Y["R"] = Y["P"] + 1) in s_enum (src/js_parser/visit/visit_stmt.rs:2417), like oxc and sucrase.
  3. No. The shapes become it.todo rows and this PR closes.

Problem

  • enum Z { Q = 3 } import X = Z; enum Y { P = X.Q, R } emits Y[Y["R"] = undefined] = "R".
  • import X = Z becomes const X = Z (src/js_parser/parse/parse_typescript.rs:557) and nothing records what X names, so R gets no number.

Fix

  • Between the parse and visit passes, bind_ts_import_equals_aliases makes each alias of an enum or namespace of the file a key of ref_to_ts_namespace_member.
  • The --aot checker follows the same aliases. The printer parenthesizes a negative imported member left of **.
  • Verified: test/bundler/transpiler/transpiler.test.js (23 new tests fail on main).
  • Self-reviewed: 47 concerns raised, none addressed in code yet (Notes).

Background

  • ref_to_ts_namespace_member maps a symbol to the members of its enum or namespace. handle_identifier reads it to replace Z.Q by its value.
  • bun visits the enums of a statement list first. So the binder runs before the visit pass.
  • Considered a fold scoped to s_enum and a lazy alias entry, not option 2.

Downsides

  • Not fixed yet (Notes): bun run can drop the alias statement, it disagrees with --aot on two shapes, and an importer's write to a folded member does not parse.
  • Three of the seven shapes stay undefined.
  • Each parse pays +1 instruction (.js) or +3 (.ts), each alias +279 to +599. P grows 8 bytes, .text 9,216.
Notes

Self-review. A review of this diff said not to merge it as it is, and to ask the question above first. I checked each reason below.

Defects the review found, not fixed yet. Checked on release builds of main 3f3a9ff and of 57d7b77.

  • The alias statement does not always stay. The four lines enum Z { Q = 3 }, import X = Z;, X.Q; and console.log(eval("X.Q")) print 3 on main. This branch throws ReferenceError: X is not defined under bun run: the folded statement is removed, its part gives the use back, and the alias has no use left. bun build --no-bundle keeps the statement.
  • bun run and a --aot executable disagree on two shapes where main agrees.
    • namespace M { export function F() {} export namespace F { export enum E { A = 1 } } } import X = M.F.E; namespace N { export enum Y { P = X.A, R } } prints 1 undefined under bun run and 1 2 in the executable. Main prints 1 undefined in both.
    • namespace Outer { export namespace N { export enum Z { Q = 3 } } import A = N.Z; export namespace Later { export enum W { P = A.Q, R } } } prints 3 4 under bun run and 3 undefined in the executable. Main prints 3 undefined in both.
  • An importer's write. With export enum Y { P = X.Q, R } in one file, (Y as any).P = 14 in an importer bundles to Y.P = 14 on main and to 3 /* P */ = 14 here, which does not parse. Main prints the same text when the member is P = Z.Q. js_printer: inline a cross-module enum member only where the parser counted a read #42454 changes that printer path.
  • For a rework: use find_symbol_with_record_usage::<false> in place of the copy in find_ts_name_binding. Bind in one pass in source order, or pin the cycle guard with tests (import A = B; import B = A). Add tests with treeShaking and minify.syntax, and for exports.replace. Use it.failing rows in place of the rows that pin R: undefined.

Corrections to earlier text of this description.

Where it comes from. A fuzzer found the shapes. No user reported them. The values below are from Bun.Transpiler output run with new Function, and tsc 6.0.2 through createProgram.

Shape main this PR tsc
alias of a top-level enum: enum Z { Q = 3 } import X = Z; enum Y { P = X.Q, R } 3 undefined 3 4 3 4
alias in the namespace block of the enum 3 undefined 3 4 3 4
alias of an enum of a sibling namespace block 3 undefined 3 4 3 4
alias of a const enum 3 undefined 3 4 3 4
alias of an enum in a namespace (import X = A.Z), read by a top-level enum 3 undefined 3 undefined 3 4
alias of the namespace (import N = A, N.Z.Q), read by a top-level enum 3 undefined 3 undefined 3 4
alias of an alias, read by a top-level enum 3 undefined 3 undefined 3 4
the same three, read by an enum in a later namespace or in a function 3 undefined 3 4 3 4
alias written behind the enum that reads it ReferenceError 3 4 3 4

The three rows that stay are the visit order: bun visits a top-level enum before the body of namespace A, so the members of A.Z have no values yet. The direct name enum Y { P = A.Z.Q, R } gives 3 undefined on main for the same reason. #42118 changes that order (see the corrections above). Three it.todo rows hold tsc's values.

What else folds. X['Q'], X.Q + 1, -X.Q, a string member (no reverse key, as tsc), a merged enum, an alias of a namespace or of a nested namespace, a module-level export import X = Z, the inner of two aliases of one name, and a read outside an enum (const x = X.Q). A bundle that imports Y gets its members as numbers (ts/ImportEqualsEnumAlias).

Not in this change. Each line prints as on main. it.todo rows hold tsc's values.

  • export import X = Z inside a namespace: the bare read in its block, the bare read from a sibling block, M.X.Q from outside, and import W = M.X. The bare name of such an export has no local binding to print.
  • An alias of a member read as a bare name: import X = Z.Q; enum Y { P = X, R }.
  • An alias of an alias that is written behind it (import W = X; import X = A.Z). The enum folds, but the kept const W = X still throws at run time under bun run and --no-bundle, as on main (see the corrections above). tsc prints var.
  • A nearer binding with the name of the alias (function f(X) { enum Y { P = X.Q, R } }). tsc folds past the parameter. bun binds the parameter, on purpose.
  • X?.Q does not fold, like Z?.Q.
  • A top-level alias statement whose every read folded is not dropped. tsc drops it when it compiles a program, and keeps it per file.

The --aot checker. resolve_member_path (src/sema/check/aot/enum_constants.rs) says which enum reads the transpiler replaces by values. It refused every first name that is an alias. is_first_name_followed now follows an entity alias that no namespace exports, whose first name is an enum or a namespace of the file, an export of another part of its namespace, or another such alias. A probe compares 25 alias reads in the transpiler's output with the checker's answers: 24 agree. The last one is an alias through a namespace that shares a block with a function of its name (import X = M.F): the checker follows it and the transpiler does not, as for the direct path M.F.E.A on main. #46938 has the rule for that shape.

Overlaps.

Measurements. Release builds of main 3f3a9ff and of 57d7b77 in one container. The commit after 57d7b77 only turns the method find_ts_name_binding into a free function. Instructions are user-mode counts.

What main this PR
size_of::<P>() 4,432 B 4,440 B
ParserSnapshot 416 B 416 B
P::init, one-statement .js and .ts (gdb single-step) 6,128 and 6,127 6,129 and 6,128
whole _parse of a one-statement .js (gdb single-step) 30,012 30,013
whole _parse of a one-statement .ts (gdb single-step) 34,928 34,931
one folded enum read, maybe_rewrite_property_access_for_namespace (gdb single-step) 300 303
per statement in a file with one alias and no enum (slope, 20,000 to 40,000 statements) 3,848.04 3,847.05
the same file with const in place of the alias 3,848.04 3,847.04
per import F = Baz.Bar that names nothing of the file (slope, 1,000 to 4,000) 9,827 10,106 (+279)
per namespace with an enum, an alias and an enum that reads it (slope) 44,568 46,049 (+1,481)
per link of a chain written leaf first (slope; 100,000 links: +1,031) 3,677 4,766 (+1,088)
.text 110,632,729 B 110,641,945 B (+9,216)
stripped binary 140,474,656 B 140,482,848 B (+8,192)
  • Bytes by function (nm -S): handle_identifier, find_symbol_with_record_usage, maybe_rewrite_property_access, maybe_rewrite_property_access_for_namespace, s_enum and visit_stmts are identical in every instantiation. New bind_ts_import_equals_aliases 4,591 (one copy). P::init +10 in each of four copies. parse_type_script_import_equals_stmt +111 and +97. parse_type_script_namespace_stmt +26 in two copies. _parse::<true> +33. print_expr -338 to +465 in five copies, and prints_as_imported_enum_value 191 in five copies. Checker: is_first_name_followed 3,131, resolve_member_path +64, is_declared_around (457) inlined.
  • The slopes are whole-process counts of bun build --no-bundle from perf_event_open, minimum of 5 runs, spread under 700. The chain of 100,000 aliases written leaf first resolves (console.log(3 /* Q */)) with no recursion.
  • Output on existing code: 24 of 76,434 outputs differ between the two builds. That is 12,740 tracked .ts, .tsx, .mts and .cts files under six Bun.Transpiler option sets (default, inline with target bun, minify.syntax, all of minify, treeShaking with trimUnusedImports, deadCodeElimination off). The 24 are four programs of test/aot/parallel/enums under each set: three read an enum through an alias, and one has a negative member left of **. The alias statements of those files are printed as before (2 and 7).
  • --aot parity: test/aot/run-parallel.ts with the release build passes 24 of 24 programs of test/aot/parallel/enums, compiled and with --aot-erase-types. enums-namespaces-read-paths-brackets.ts reads 8 members through aliases on 3 lines: plain Bun and the executable now both print the values.
  • Runtime transpiler cache, a 146 KB file: the first run of this build writes the entry again (version 51 to 52) and prints 3 4 where main printed 3 undefined. The second run reads it.

Tests. transpiler.test.js: 38 new tests and 9 it.todo rows. 23 of the 38 fail on main, and 15 pin what must not change. ts/ImportEqualsEnumAlias fails on main. checker-queries-narrowing.test.ts: 38 pass, one new fixture (enum-access-import-equals). New program test/aot/parallel/enums/enum-read-through-import-equals-alias.ts.

Also ran (debug build, all pass): the rest of transpiler.test.js and esbuild/ts.test.ts, esbuild/{importstar_ts,dce}, bundler_edgecase, transpiler/runtime-transpiler, cli/run/transpiler-cache.


[human-review] gate passed · iteration 1 · 11 files touched

fails on main (without fix)
ASAN without fix: 24 failed, 47 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/esbuild/ts.test.ts test/bundler/transpiler/transpiler.test.js
bun test v1.4.4 (367d939d9)

test/bundler/esbuild/ts.test.ts:
(pass) bundler > ts/DeclareConst [234.51ms]
(pass) bundler > ts/DeclareLet [65.98ms]
(pass) bundler > ts/DeclareVar [54.16ms]
(pass) bundler > ts/DeclareClass [53.29ms]
(pass) bundler > ts/DeclareClassFields [384.79ms]
(pass) bundler > ts/DeclareFunction [57.27ms]
(pass) bundler > ts/DeclareNamespace [54.36ms]
(pass) bundler > ts/DeclareEnum [54.80ms]
(pass) bundler > ts/DeclareConstEnum [53.26ms]
(pass) bundler > ts/ConstEnumComments [314.41ms]
(pass) bundler > ts/ImportEmptyNamespace [301.48ms]
(pass) bundler > ts/ImportMissingES6 [56.54ms]
(pass) bundler > ts/ImportMissingUnusedES6 [57.95ms]
(todo) bundler > ts/ExportMissingES6
(pass) bundler > ts/ImportMissingFile [46.37ms]
(pass) bundler > ts/ImportTypeOnlyFile [302.29ms]
(pass) bundler > ts/ExportEquals [370.33ms]
(pass) bundler > ts/ExportNamespace [344.39ms]
(todo) bundler > ts/MinifyEnum
(pass) bundler > ts/MinifyEnumExported [202.03ms]
(pass)
... (truncated)

release without fix: 24 failed, 47 skipped
bun test v1.4.4-canary.1 (919377090)

test/bundler/esbuild/ts.test.ts:
(pass) bundler > ts/DeclareConst [79.97ms]
(pass) bundler > ts/DeclareLet [11.59ms]
(pass) bundler > ts/DeclareVar [3.33ms]
(pass) bundler > ts/DeclareClass [4.20ms]
(pass) bundler > ts/DeclareClassFields [20.72ms]
(pass) bundler > ts/DeclareFunction [3.97ms]
(pass) bundler > ts/DeclareNamespace [4.27ms]
(pass) bundler > ts/DeclareEnum [4.11ms]
(pass) bundler > ts/DeclareConstEnum [5.52ms]
(pass) bundler > ts/ConstEnumComments [16.90ms]
(pass) bundler > ts/ImportEmptyNamespace [12.48ms]
(pass) bundler > ts/ImportMissingES6 [4.14ms]
(pass) bundler > ts/ImportMissingUnusedES6 [3.77ms]
(todo) bundler > ts/ExportMissingES6
(pass) bundler > ts/ImportMissingFile [3.21ms]
(pass) bundler > ts/ImportTypeOnlyFile [14.77ms]
(pass) bundler > ts/ExportEquals [18.48ms]
(pass) bundler > ts/ExportNamespace [11.43ms]
(todo) bundler > ts/MinifyEnum
(pass) bundler > ts/MinifyEnumExported [21.54ms]
(pass) bundler > ts/MinifyNestedEnum [4.30ms]
(todo) bundler > ts/MinifyNestedEnumNoLogicalAssignment
(todo) bundler > ts/MinifyNestedEnumNoArrow
(pass) bundler > ts/MinifyNamespace [3.71ms]
(todo) bundler > ts/MinifyName
... (truncated)
passes on PR (with fix)
ASAN with fix: 47 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/esbuild/ts.test.ts test/bundler/transpiler/transpiler.test.js
bun test v1.4.4 (367d939d9)

test/bundler/esbuild/ts.test.ts:
(pass) bundler > ts/DeclareConst [256.48ms]
(pass) bundler > ts/DeclareLet [68.83ms]
(pass) bundler > ts/DeclareVar [61.88ms]
(pass) bundler > ts/DeclareClass [61.79ms]
(pass) bundler > ts/DeclareClassFields [466.84ms]
(pass) bundler > ts/DeclareFunction [66.72ms]
(pass) bundler > ts/DeclareNamespace [60.14ms]
(pass) bundler > ts/DeclareEnum [65.50ms]
(pass) bundler > ts/DeclareConstEnum [73.35ms]
(pass) bundler > ts/ConstEnumComments [383.51ms]
(pass) bundler > ts/ImportEmptyNamespace [570.61ms]
(pass) bundler > ts/ImportMissingES6 [66.88ms]
(pass) bundler > ts/ImportMissingUnusedES6 [63.61ms]
(todo) bundler > ts/ExportMissingES6
(pass) bundler > ts/ImportMissingFile [52.14ms]
(pass) bundler > ts/ImportTypeOnlyFile [366.54ms]
(pass) bundler > ts/ExportEquals [382.78ms]
(pass) bundler > ts/ExportNamespace [406.78ms]
(todo) bundler > ts/MinifyEnum
(pass) bundler > ts/MinifyEnumExported [275.03ms]
(pass)
... (truncated)

release with fix: 47 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1046ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/421] build.rs build_script_build
[2/421] gen generated_host_exports.rs
generated_host_exports.rs: 119 exports (lazy=4, generic=115, rust=0); 288 extern-C blocks audited
[3/420] rustc bun_safety 
[4/420] rustc bun_zlib_sys 
[5/420] rustc bun_boringssl_sys 
[6/420] rustc bun_output 
[7/420] rustc bun_perf 
[8/420] rustc bun_cares_sys 
[9/420] rustc bun_zstd 
[10/420] rustc bun_picohttp 
[11/420] rustc bun_base64 
[12/420] rustc bun_ptr 
[13/420] rustc bun_errno 
[14/420] rustc bun_brotli 
[15/420] rustc bun_clap 
[16/420] rustc bun_tcc_sys 
[17/420] rustc bun_lsquic_sys 
[18/420] rustc bun_valkey 
[19/420] rustc bun_text_diff 
[20/420] rustc bun_zlib 
[21/420] rustc bun_paths 
[22/420] rustc bun_collections 
[23/420] rustc bun_shell_parser 
[24/420] rustc bun_url 
[25/420] rustc bun_semver 
[26/420] rustc bun_png 
[27/420] rustc bun_analytics 
[28/420] rustc bun_tar 
[29/420] rustc bun_which 
[30/420] rustc bun_node_path 
[31/420] rustc bun_http_types 
[32/420] rustc bun_boringssl 
[33/420] rustc bun_dns
... (truncated)
diff hotspot
src/js_parser/p.rs                                 | 128 +++++++++++-
 src/js_parser/parse/parse_typescript.rs            |  14 +-
 src/js_parser/scan/scan_symbols.rs                 |  31 +++
 src/js_printer/lib.rs                              |  29 ++-
 src/jsc/RuntimeTranspilerCache.rs                  |   3 +-
 src/sema/check/aot/enum_constants.rs               |  73 ++++++-
 test/aot/checker-queries/narrowing.programs        |  68 +++++++
 .../main.ts                                        |   2 +-
 .../enums/enum-read-through-import-equals-alias.ts |  90 +++++++++
 test/bundler/esbuild/ts.test.ts                    |  20 ++
 test/bundler/transpiler/transpiler.test.js         | 225 +++++++++++++++++++++
 11 files changed, 669 insertions(+), 14 deletions(-)

gate history · 1 passed · 0 rejected · iteration 1

evidence per changed file
file                                                      reads  edits  tests
src/js_parser/p.rs                                           14      8     77
src/js_parser/parse/parse_typescript.rs                       5      1     78
src/js_parser/scan/scan_symbols.rs                            2      1     77
src/js_printer/lib.rs                                         1      1     77
src/jsc/RuntimeTranspilerCache.rs                             2      3     79
src/sema/check/aot/enum_constants.rs                          4      3     77
test/aot/checker-queries/narrowing.programs                   0      0     84
…l/enums/const-enum-replaced-by-values-path-read/main.ts      0      0     81
…parallel/enums/enum-read-through-import-equals-alias.ts      0      0     79
test/bundler/esbuild/ts.test.ts                               1      1     49
test/bundler/transpiler/transpiler.test.js                    4      3     64

`import X = A.B` became `const X = A.B` and nothing recorded what X
names. A read through X was never replaced by a value, so an enum
initializer `P = X.Q` was no constant and the next member got
`undefined`.

The parse pass now records each entity alias. Between the parse and the
visit passes, bind_ts_import_equals_aliases gives the alias the members
of the enum or namespace of this file that it names, as a second key of
ref_to_ts_namespace_member. The readers of that table do not change.
The alias statement stays printed: a fold through the alias keeps its
use.
The build for --aot asks the checker which enum reads the transpiler
replaces by values. resolve_member_path refused every first name that is
an alias. It now follows an `import X = A.B` alias as the transpiler
binds it: an entity alias that no namespace exports, whose first name is
an enum or a namespace of the file, another part's export of it, or
another such alias.

The printer also puts a member of an enum of another module in
parentheses left of `**` when its value is printed there: a member that
the alias fold made a constant can be a negative number.

Tests: transpiler.test.js (38 new, 23 of them fail on main, 9 todo rows
hold tsc's values), ts/ImportEqualsEnumAlias, the fixture
enum-access-import-equals of test/aot/checker-queries, and the program
test/aot/parallel/enums/enum-read-through-import-equals-alias.ts.
An alias that names an alias written before it finds that one bound, so
the index of the aliases is built only for a chain that is written leaf
first. One buffer holds the names of every target.
@robobun

robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:11 AM PT - Nov 2nd, 2026

❌ @robobun, your commit 9dda072 has 40 failures in Build #136752 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47708

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

bun-47708 --bun

@robobun

robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on bun 1.4.3 and on a build of main 3f3a9ff. This program prints 3 undefined under bun run, bun build, bun build --minify, bun build --no-bundle and Bun.Transpiler:
    enum Z { Q = 3 }
    import X = Z;
    enum Y { P = X.Q, R }
    console.log(Y.P, Y.R);
  • tsc 6.0.2 prints 3 4. With this branch all five paths print 3 4.
  • The pull request is js_parser: bind import X = A.B to the enum or namespace it names #47708. It is a parked draft. It waits for a maintainer's answer to the question at the top of the description: may the transpiler print an enum value that esbuild does not? A self-review found three defects that are not fixed yet. The description lists them.
  • CI on 9dda072 (build 136752): 28 test files are red, and none is a test of this change. The same 28 are red on the build of main at the merge base (3f3a9ff, build 136408). The diff needs no change for them.

The scope walk reads only its arguments. As a method of the parser it was
compiled once per parser instantiation.

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