Skip to content

js_printer: print a space between a number and the . of a private name - #47722

Open
robobun wants to merge 4 commits into
mainfrom
robobun/1050cf3f/private-name-after-number
Open

robobun wants to merge 4 commits into
mainfrom
robobun/1050cf3f/private-name-after-number

Conversation

@robobun

@robobun robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun run stops with SyntaxError: Unexpected private name #x. Expected a ';' following a return statement. on a valid file that reads (1).#x. Node loads it. bun build output has the same fault.
  • The printer writes 1.#x, and 1. is the number. The a.#b arm prints the . with no check (src/js_printer/lib.rs:3760). The a.b arm has it (:3690).

Fix

  • Both arms call print_member_dot. After a number literal it prints a space first: 1 .#x.
  • The check reads the printed text: it covers a literal, a folded or inlined constant, an enum member, a --define value.
  • The transpiler cache version goes from 51 to 52. A fixed build loaded an entry of main that holds 1.#x.
  • Verified: test/bundler/transpiler/transpiler.test.js, test/bundler/bundler_minify.test.ts, test/cli/run/transpiler-cache.test.ts. The 4 new tests fail on the merge base. Other suites: Notes.

Background

  • The AST keeps no parentheses: (1).#x is a member access on a number. prev_num_end is the output position after the last number literal.
  • The transpiler cache keeps printed output on disk. bun run uses an entry only if its version is EXPECTED_VERSION.
  • Considered copying the check: same machine code, two places for one rule.

Downsides

  • A number with a fraction also gets the space (1.5 .#x), as .x does, though 1.5.#x parses.
  • Each printed .#name runs 3 more instructions, and .name none. Release bun (linux-x64): size text +512 bytes.
  • After the upgrade, each cached file is transpiled one more time.
Notes

Source. #47712 names this fault: number 6 in its list of faults of main. No issue exists for it.

Repro. Node prints TypeError for both files.

// a.js
class A { #x = 1; static t() { try { return (1).#x; } catch (e) { return e.name; } } }
console.log(A.t());
// b.js: the same with `const k = 1;` in front and `k.#x` as the read

Release builds of the merge base (3f3a9ff) and of this PR:

file mode merge base this PR
a.js bun a.js SyntaxError: Unexpected private name #x. TypeError
a.js bun build --no-bundle --minify-syntax, then run error: Expected ";" but found "#x" TypeError
a.js bun build, then run error: Expected ";" but found "#x" TypeError
a.js bun build --minify, then run error: Expected ";" but found "#t" TypeError
b.js bun b.js SyntaxError: Unexpected private name #x. TypeError
b.js bun build --no-bundle --minify-syntax, then run SyntaxError: Unexpected private name #x. TypeError
b.js bun build, then run TypeError TypeError
b.js bun build --minify, then run error: Expected ";" but found "#t" TypeError

The control (1).x prints undefined in every mode on both builds.

Other forms.

  • A source that already reads 1 .#x or 1..#x failed in the same way, because the printer prints what it parsed.
  • (1 + 1).#x: where the sum is folded (bun run, --minify-syntax), the merge base printed 2.#x.
  • (1)?.#x: where the ?. after a number is dropped (bun run, --minify-syntax), the merge base printed 1.#x. Where the printer keeps the ?., the output is 1?.#x on both builds.
  • A const enum member from another file is inlined by the printer. E.One.#x printed 1.#t in a minified bundle. Without --minify it prints 1 /* One */.#x, which parses.
  • With --define CFG.n=10, CFG.n.#x printed 10.#x.
  • These forms need no space and do not change: 1n.#x, (-1).#x, 1[0].#x.
  • A script with 22 shapes of a number in front of a private name runs each shape as it stands and builds it in four ways with --target=bun. Every cell is TypeError on this PR. 16 of the 22 shapes fail in some cell on the merge base.

The transpiler cache. bun run stores the printed output of a file of 4 KiB or more. The merge base stores the entry for a file that reads (1).#x, though the file then fails to load. Release builds, one cache folder, 10 runs for each pair:

the entry was written by the file is run by result with that entry
merge base this PR without the version change SyntaxError: Unexpected private name #x. in 10 of 10
merge base this PR TypeError in 10 of 10
this PR this PR TypeError in 10 of 10

The new test in transpiler-cache.test.ts gives a fresh entry the output 1.#x. It first shows that the entry is used under the current version. Then it sets the version of the entry to 51 and expects TypeError. On a build with the printer fix and version 51, that last step fails with the SyntaxError.

esbuild. On main, the EPrivateIdentifier case of EIndex in internal/js_printer/js_printer.go prints the . with no check. esbuild 0.25.1 prints return 1.#x; for a.js.

Measurements. Release builds (--profile=release, LTO, linux-x64) in one build directory: the merge base, then this PR.

  • size bun: text 140,288,644 to 140,289,156 (+512 bytes). data and bss are equal. The file is 140,478,752 bytes in both builds. The version change adds no byte.
  • nm -S: print_expr has five instantiations. They grow by 34, 36, 39, 34 and 434 bytes. The last one is the printer for Bun with source maps. It has 32 more instructions (3,472 to 3,504). The other bytes are from the register allocator: self moved from r13 to r12, and each operand with r12 as its base is one byte longer.
  • objdump -d: the a.b arm has the same four instructions for the check in both builds (two loads, cmp, jne). The a.#b arm now has them too. One of the two loads was there before, so the arm runs 3 more instructions.
  • With a plain #[inline], the release build kept print_member_dot out of line: 129 bytes in each instantiation, and a call for each printed .name. So the helper is #[inline(always)].
  • perf and valgrind are not in the build container. The instruction counts are from the disassembly, not from a run.

Open PRs that touch the same code. #42448 changes the flags that the EIndex arm passes to its target. #41156 changes when print_number records prev_num_end. Both keep prev_num_end, and the helper reads it. Other open PRs also add a Version 52 line to RuntimeTranspilerCache.rs. The one that merges later takes the next number.

Suites, debug build.

  • On the last commit: test/cli/run/transpiler-cache.test.ts (35 pass, 0 fail), test/bundler/transpiler/transpiler.test.js (260 pass, 0 fail), test/bundler/bundler_minify.test.ts (68 pass, 0 fail), test/regression/issue/30887.test.ts and 28159.test.ts (6 pass, 0 fail).
  • On the first commit, which has the same printer code: test/bundler/bundler_edgecase.test.ts (197 pass, 0 fail), test/bundler/esbuild/default.test.ts, dce.test.ts, ts.test.ts (301 pass, 0 fail), test/bundler/transpiler/es-decorators.test.ts, decorators.test.ts, runtime-transpiler.test.ts, property.test.ts (470 pass, 0 fail).

no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/cli/run/transpiler-cache.test.ts

`(1).#x` printed as `1.#x`, which does not parse: the lexer reads `1.` as
the number. The arm that prints `a.b` already writes `1 .b` for this case.
The arm that prints `a.#b` did not have the check.

Both arms now call `print_member_dot`, which holds the check.
@robobun

robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:24 AM PT - Nov 2nd, 2026

❌ @robobun, your commit d99ed92 has 40 failures in Build #136821 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47722

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

bun-47722 --bun

@robobun

robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status. The fix is in this PR (#47722).

I reproduced the fault on a release build of main (3f3a9ff) with this file:

class A { #x = 1; static t() { try { return (1).#x; } catch (e) { return e.name; } } }
console.log(A.t());
  • node a.js prints TypeError.
  • bun a.js stops with SyntaxError: Unexpected private name #x. Expected a ';' following a return statement.
  • bun build --no-bundle a.js prints return 1.#x;.

A second fault came from the runtime transpiler cache. I reproduced it with release builds and one cache folder, 10 runs for each pair:

  • Main writes the entry for the file (padded to 4 KiB), and the PR at d42e69a runs the file: SyntaxError in 10 of 10 runs.
  • Main writes the entry, and the PR with cache version 52 runs the file: TypeError in 10 of 10 runs.

CI. No failure in build #136821 (commit d99ed92) is from this PR. The build is complete.

  • 28 tests are red: 27 under test/aot/, and test/bundler/compile-aot-size.test.ts. This PR does not touch them. The same 28 are red in build #136806 of the commit before.
  • Each of the 28 is also red on the final builds of the last two merged PRs: ci: look up Linux images with an AWS role #47625 (#136407) and ci: canary release uses a GitHub App token and does not wait for tests #47622 (#136399).
  • compile-aot-size reads hello world --trim maps 25957348 bytes against a bound of 25880000. The build of ci: look up Linux images with an AWS role #47625 reads 25961444.
  • The three test files of this PR pass on each lane that I read: ubuntu 25.04 x64, debian 13 x64, debian 13 x64-asan, alpine 3.23 x64, Windows 2019 x64, Windows 11 aarch64, macOS x64 and macOS aarch64. The counts are test/bundler/transpiler/transpiler.test.js (260 pass, 0 fail), test/bundler/bundler_minify.test.ts (68 pass, 0 fail) and test/cli/run/transpiler-cache.test.ts (35 pass, 0 fail. On Windows 34, because one older test is skipped there).
  • No lane reports a failure in these three files.

@coderabbitai

coderabbitai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between d42e69a and d99ed92.

Files selected for processing: 3
  • src/jsc/RuntimeTranspilerCache.rs
  • test/bundler/transpiler/transpiler.test.js
  • test/cli/run/transpiler-cache.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.

Walkthrough

The printer now uses a shared helper to emit member-access dots, adding a space when a number ends at the current output position. Regression tests cover printed output and runtime behavior for private-name access through numeric literals and inlined values. The transpiler cache version changes from 51 to 52.

Changes

Numeric member-dot spacing

Layer / File(s) Summary
Member-dot printing and regression coverage
src/js_printer/lib.rs, test/bundler/transpiler/transpiler.test.js, test/bundler/bundler_minify.test.ts
The dot-property and private-index paths use print_member_dot to handle spacing after numeric literals. Tests cover printer output, minification, substitutions, and runtime TypeError results.
Transpiler cache versioning
src/jsc/RuntimeTranspilerCache.rs, test/cli/run/transpiler-cache.test.ts
The cache version changes from 51 to 52. A regression test checks execution of cached output with the current and previous version fields.

Suggested reviewers: jarred-sumner, dylan-conway

Merge Risk: Minimal · up to d99ed

The change addresses numeric private-member printing and invalidates older cached output. No concrete merge-blocking issue is established; merge after normal checks pass.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main printer change: inserting a space between a number and the dot in private-name access.
Description check Passed The description is detailed and on-topic. It explains the problem, fix, verification steps, tests, cache-version change, tradeoffs, and known limitations. It does not use the exact required headings, …
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.
Full details: Description check

Explanation

The description is detailed and on-topic. It explains the problem, fix, verification steps, tests, cache-version change, tradeoffs, and known limitations. It does not use the exact required headings, but it provides the required information through equivalent sections.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

LGTM — a small, self-contained printer fix: the existing prev_num_end space check from the a.b arm is lifted into print_member_dot and reused in the a.#b arm, and the tests cover the literal, inlined-const, define, minified, and bundled const-enum paths plus the negative cases.

Beyond the inline nit, I also checked the other self.print(b".") sites in src/js_printer/lib.rs (namespace alias and CommonJS export paths) — they follow a printed symbol, never a number, so they are not affected; and the (-1).#x case is safe because negative numbers at postfix level are already wrapped in parentheses by print_number.

Extended reasoning...

The change touches only src/js_printer/lib.rs (extracting an existing four-line check into an inline helper and calling it from the EPrivateIdentifier branch of EIndex) plus two test files; no security-sensitive surface is involved. The refactor is behavior-preserving for the EDot arm and the EIndex arm's optional-chain path is untouched. The new tests assert exact printed output and a combined {stdout, stderr, exitCode} object from a spawned subprocess, and would fail on the merge base since the old output was a syntax error. No CODEOWNERS entry covers the changed files and the only reported finding is a serial-vs-concurrent test nit.

@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 — the follow-up commit addresses the earlier nit (the spawn test is now it.concurrent and named after bun -e), and the fix itself is a small, behavior-preserving extraction.

What was reviewed:

  • print_member_dot is byte-for-byte the old EDot non-optional branch; the EIndex/EPrivateIdentifier arm still skips it when ?. was printed, so 1?.#x is unchanged.
  • prev_num_end is only recorded for non-negative and unwrapped-negative numbers in print_number, so (-1).#x, 1n.#x, and 1[0].#x get no space; the remaining print(b".") sites in the printer follow identifiers/namespace refs, not numbers, so no sibling site is missed.
  • Tests cover literal, 1 ., 1.., call, assignment, minifyWhitespace, inline, define, inlined const enum under full minify, plus the negative cases, and the spawn test drains all three pipes with Promise.all.
Extended reasoning...

The change touches one function pair in src/js_printer/lib.rs (12 lines net), extracting the existing number-then-dot guard into an always-inlined helper and calling it from the private-name member arm, plus three new tests in test/bundler. It touches no security-sensitive surface. The refactor is verified behavior-preserving by reading both arms and the two prev_num_end assignment sites, no CODEOWNERS entry covers the changed files, the only prior feedback (my own nit) was addressed by the second commit, and the bug hunt ran to a dry streak with no findings.

The printer now writes `1 .#x` for a private name read off a number. An
entry that version 51 wrote for such a file holds `1.#x`. A build with the
printer fix still loaded that entry and failed with the same SyntaxError.

The tests also cover a folded constant and a folded `?.` in front of a
private name.
Comment thread src/jsc/RuntimeTranspilerCache.rs Resolved

@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 — the EIndex private-name arm now reuses the exact prev_num_end check the EDot arm already had, and the follow-up commits add the cache version bump plus a test for it.

What was reviewed:

  • print_member_dot() is byte-for-byte the former inline EDot code; the remaining print(b".") sites in the printer (namespace alias, CommonJS export key) always follow a symbol, never a number, so they are correctly left alone.
  • Optional-chain paths (1?.x, 1?.#x) stay unspaced, which is valid; (-1).#x goes through the parenthesized negative path that does not set prev_num_end.
  • The transpiler-cache test's byte offsets (30/38/46) and wyhash seed match Metadata::encode and SEED in RuntimeTranspilerCache.rs; the 5 KiB fixture clears MINIMUM_CACHE_SIZE and the cache dir holds one file in both debug and release builds.
  • The serial-spawn nit from the previous review is addressed (it.concurrent, title now says bun -e).
Extended reasoning...

The diff touches src/js_printer/lib.rs (a 10-line helper extraction used at the EDot and EIndex-private-name arms), bumps EXPECTED_VERSION 51 to 52 in src/jsc/RuntimeTranspilerCache.rs, and adds tests in three files covering literal, inlined-constant, define, folded, optional-chain, and negative cases plus a cache-rejection test. No security-sensitive surface is involved; the only cross-cutting concern is the on-disk cache format, and the version constant is bumped as REVIEW.md requires. The helper is behavior-identical to the code it replaced, the sibling .-printing sites were audited and provably never follow a number, and no CODEOWNERS entry covers the changed files, which decided approve over defer.

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