Skip to content

js_parser: print a read of one member of an object or array define as that member - #47712

Open
robobun wants to merge 3 commits into
robobun/fd834d72/string-folds-non-ascii-textfrom
robobun/3bf1c9e5/define-object-key-read
Open

robobun wants to merge 3 commits into
robobun/fd834d72/string-folds-non-ascii-textfrom
robobun/3bf1c9e5/define-object-key-read

Conversation

@robobun

@robobun robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #21312

Stacked on #47655.

Problem

  • bun build --define AWS='{"ACCESS_KEY":"abc","SECRET_KEY":"def"}' prints console.log({ ACCESS_KEY: "abc", SECRET_KEY: "def" }.ACCESS_KEY); for console.log(AWS.ACCESS_KEY);.
  • The parser replaces AWS with the whole value before it visits .ACCESS_KEY (value_for_define, src/js_parser/p.rs:7136).

Fix

  • A member access on an object or array define copies only the member it reads: AWS.ACCESS_KEY, AWS["ACCESS_KEY"], CFG.a.b, LIST[0].
  • Every other use copies the whole value: a missing or computed key, ?., a store, a call.
  • fold_define_member (src/sema/fold) folds the same reads for --compile --aot.
  • Verified: transpiler.test.js, esbuild/extra.test.ts, bake/dev/hot.test.ts, two test/aot files. Self-reviewed: 124 concerns raised, 99 addressed (Notes).

Background

Downsides

Notes

Why stacked. A string that a read takes out of the value is a string literal to the rules for one. On main those rules read 8-bit text that is not ASCII by byte, and fold "abc".length under a store. #47655 puts both conditions on the rules. With it, CFG.s[1] for {"s":"aéb"} is "é", (CFG.s + "x").length is 4, and CFG.s.length = 0 stays a store. Tests for these rows are in this PR.

What each use prints. AWS is {"ACCESS_KEY":"abc","SECRET_KEY":"def"}, CFG is {"a":{"b":{"c":7}},"list":["z",{"x":2}]}.

Use Printed
AWS.ACCESS_KEY, AWS["ACCESS_KEY"], AWS[`ACCESS_KEY`] "abc"
CFG.a.b.c, CFG.a.b, CFG.list[1].x, CFG.list.length 7, { c: 7 }, 2, 2
AWS, AWS.REGION, AWS?.ACCESS_KEY, AWS[key], AWS["ACCESS" + "_KEY"] the whole object, then the rest as written
AWS.ACCESS_KEY = 1, delete AWS.ACCESS_KEY, AWS.ACCESS_KEY(), const { ACCESS_KEY } = AWS the whole object, then the rest as written
CFG.a.missing, CFG.a.b(), CFG.list.map(f), CFG.list[0] = 1, delete CFG.a.b the whole object, then the rest as written
  • The key is a name, or a string, a template without substitutions or a number that is written as a literal. The parser looks at the index before it visits it. So for AWS["ACCESS" + "_KEY"], AWS[KEY] with a constant and LIST[1 - 1] the define puts the whole value there in every mode, and fold_define_member reads the same literal. A rule of minified syntax can still fold that copy: with LIST=[10,[20],30], LIST[1 - 1] prints 10 there and under bun run, as on the base.
  • A string reads a key of an object, and a number reads an item of an array. LIST["0"] and OBJ[0] keep the whole value. So do __proto__ and a string in brackets that is not ASCII (CFG["é"]): the parser stores such a string as UTF-16, and treats no x["é"] as x.é on main. CFG.é is replaced.
  • A chain folds as a whole or not at all. Where the last link takes no member, the define puts its whole value under the chain, and the output for that use is the output of the base, byte for byte. The read visits its target again for that (keeps_define_whole), unless the target is the identifier of the define itself.
  • Minified syntax puts the value of a local that is read once in place of the read and visits the expression again (let p = process; return p.env.FOO + 1;). A define keeps its whole value in that visit, as on the base. A key that the first visit computed is no literal of the source there.
  • A define of the path itself comes first (--define CFG.a=1 beside CFG), and drop applies as before.

Results that change. Each is what --define CFG.key=value gives on main.

  • The kind of a file under bun run --define. With SETTINGS={"debug":false,"level":3}, if (SETTINGS.debug) require("node:path"); left require in use, and the file was CommonJS. Now it is an ES module. A one-key object already folds under bun run on main. The rows are in a-file-whose-only-require-a-define-removes-is-a-module.test.ts.
  • require(CFG.path), require.resolve(CFG.path) and import(CFG.path) are resolved by bun build. A file that is not there fails the build with Could not resolve. external and a read through a variable (const cfg = CFG; require(cfg.path)) leave the specifier for run time. extra/DefineObjectMemberAs* pin the three cases. In an --aot executable such a require loads the file. On the base it fails at run time.
  • enum E { A = CFG.a, B } with CFG={"a":1}: E.B is 2, not undefined. A string member makes a string member of the enum: enum E { A = CFG.s } has no E["abc"].
  • In the dev server, import.meta.hot.accept(CFG.deps, cb) is a static dependency list. On main it throws when the page loads. hot.test.ts pins it.
  • A macro argument and a feature() argument can be a member read. Not pinned in this PR.
  • An error under bun run quotes the member ("abc".x), not the object.
  • bun build drops an unused class F extends CFG.max {}, as it drops class F extends 10 {} and class F extends { a: 1 } {}. The TypeError of that statement is gone.

Faults of main that a replaced read now reaches. None is specific to an object define. A define of the same path, or the same literal in the source, has each of them on main. The first 8 show under bun run or bun build. The other 8 are differences between bun run and an --aot executable.

  1. Readers of the first part of a joined string: require.resolve(CFG.dir + "/dep.js") resolves ./lib (Make EString::string() join folded ropes, fixing require.resolve() and other readers of the first segment #38944).
  2. A dead statement keeps its tracked uses: with --minify-syntax --target=bun, const h = require("./esm.js"); if (h && CFG.debug) log(); const call = name => h[name](); fails at run time (js_parser: roll back a removed statement's tracked uses along with its use counts #45345).
  3. switch (x) { case 0: const Y = CFG.n; case 1: return Y; } with --no-bundle --minify-syntax: the declaration is dropped and the read in the later case stays. Main does this for const Y = 1.5.
  4. A constant that a member initializes is inlined into a with block, where a property of the object has to come first. Main does this for const Y = 1.5.
  5. A string member that is a statement by itself is printed as a directive where dead code stays (bun test --coverage): CFG.s; with the value "use strict".
  6. The printer prints (1).#x as 1.#x. CFG.n.#x with a number member reaches it.
  7. import.meta[CFG.k] with the value "main" is import.meta.main, which --format=iife prints with a __require that is not there.
  8. With --minify, the printer puts no space between a name that ends in a character that is not ASCII and in or instanceof: obj[CFG.u] in y with the value "é" prints o.éin e. Main prints the same for x.é in y.
  9. Text: toString() of an arrow function that ends in a define whose value is longer than the text of its read is cut to the length of that text, and a quote in an error message repeats the end of such a value. Main does this for () => SHORT. A member that is longer than its read is cut in the same way.
  10. Text: the text shows no rule that the printer applies to a literal. typeof CFG.n, void CFG.n, CFG.s === "str" and !CFG.o print as typeof 10, void 10, "str" === "str" and !{ k: 1 }. Main prints typeof 3 for typeof LEVEL.
  11. Kind of a file: the model does not know every rule that bun run applies to a literal. A require behind NAME.length, NAME[0], NAME.charCodeAt(0), ({ k: DEBUG }).k, a constant const X = typeof ZERO or an enum member (enum E { A = 1 }, E.A !== 1), and the statements -FLAG, module; and [...LIST, module];, leave an ES module under bun run and CommonJS in the executable on main. The same reads of a member now differ in the same way (SETTINGS.name.length, enum E { A = CFG.one }). enum E { A = S.name, B = A } has E["n"] in the executable and not under bun run, as A = NAME has on main. AOT: model the member reads of literals that bun run folds #47646 models these rules for literals in the source.
  12. Kind of a file, the other way: if (CFG.a && CFG.list) {} else require("node:path"); with an object and an array member is CommonJS under bun run and an ES module in the executable. Main has this for if (OBJ && ARR) with two defines. const X = CFG.off; if (X) module.exports = 1; behind an import statement is an error under bun run and runs in the executable, as const X = false does on main.
  13. feature(CFG.flag): bun run and bun build --compile take it. The --aot build stops with TS100101: feature() argument must be a string literal, as it does for feature(NAME) on main.
  14. A top-level return, new.target or delete x in an arm that a member rules out: bun run runs if (CFG.off) return;, and the --aot build stops with TS1108. Main has this for if (OFF) return;.
  15. A define path with a link in brackets: bun run has x["name"] as x.name, and a build without --minify-syntax has not. With --define app.debug=false, app["debug"] is false under bun run and a ReferenceError in the executable on main. With a process.env object, process["env"].DEBUG is now an ES module under bun run and CommonJS in the executable.
  16. bun build adds process.env.NODE_ENV, process.env.BUN_ENV and process.browser by itself, and with --compile also process.platform, process.arch and process.versions.bun. bun run adds none. With --define process.env='{"NODE_ENV":"production"}' the executable prints development and bun run prints production on main. Where the two values are the same, the two now agree on the kind of the file. Where they differ, the kind differs too.

One more edge is from this PR. A read that takes no member under a chain of two or more links visits the chain a second time. With a define that is named arguments, class A { x = arguments.a.missing } then reports Cannot access "arguments" here twice. A reserved word of strict mode is reported once.

Measurements. Release builds for linux x64 of the base (#47655, bf41088d8af) and of this branch. Each row was run on the base and on the branch in turn. User-mode instructions of the whole command on all threads (perf_event_open, least of 3 runs), peak RSS, bytes of output.

bun build --no-bundle base this PR
typescript.js (9.1 MB), no define 965,692,233 964,212,904
the same with an unread 5-key object define 965,815,121 964,335,116
800,000 member reads (f(a.id, b.name);), no define 2,641,211,767 2,632,812,671
400,000 declarations (var a = 1;), no define 1,293,929,066 1,292,329,192
4,000 reads of a 4,000-key define 21,105,793,786 / 4,042.0 MB / 375,222,890 B 222,790,771 / 42.2 MB / 106,890 B
the same keys as 4,000 dot defines 307,855,994 / 45.2 MB / 106,890 B 307,983,841 / 44.3 MB / 106,890 B
4,000 reads AWS["KEY_n"] 21,099,051,100 / 4,060.0 MB 223,848,321 / 42.0 MB
4,000 reads AWS?.KEY_n (not folded) 21,107,895,658 / 4,050.3 MB 21,088,887,295 / 4,050.7 MB
4,000 reads DB.users.uN.name, 12,000 leaves 66,962,863,590 / 13,936.5 MB / 786,858,890 B 250,451,164 / 48.2 MB / 102,890 B
bun --define AWS=<4,000 keys> -e 1 (least of 20 runs) 9,177,486 9,184,474
  • The spread of 3 runs is under 2,000 instructions for the first four rows, and up to 18.3 million for the rows that copy the whole value. The 20 runs of the last row span 27,000 to 40,000 instructions on a build, and the medians are 9,189,728 and 9,194,925.
  • What a member access with no object define gains is cmpb $0xff,<slot>; jne <cold> behind the visit of its target. The three rows without a define count fewer instructions than the base. That is what the other code of visit_expr_in_out costs in this build, not a gain of the change.
  • The frame of visit_expr_in_out is sub $0x1f8,%rsp on both builds. The function is 12,238 B on the base and 12,416 B here, twice (P<false> and P<true>). read_member_of_define is cold and out of line, and takes the read and its ExprIn alone. The rule for the key is defines::own_member_of_read, which is compiled once for all parser types. find_symbol has the same code on both builds.
  • A use of a define with a value costs 32 more instructions (the dot-define row: 128,000 over 4,000 uses). leave_define_to_member_read is a call there. A test of is_property_access_target in front of that call was built and measured. The other code of visit_expr_in_out then came out differently: 38 more instructions per use in that row, typescript.js 966.1 million, 800,000 member reads 2,646.0 million. So the call stays.
  • size bun-profile: text 140,314,786 to 140,325,282 B, data and bss the same. The stripped binary grows by 8,192 B. size_of::<P>() 4,432 to 4,440 B. ExprIn stays 5 B.
  • Nothing is added to the define table: Define::insert is not in the diff.

Sizes that a project can have, with the last two releases (1 run each for the releases). This table passes the define through bunfig.toml.

keys x reads 1.4.3 1.4.4 base this PR
2 x 1 1,270,971 / 39.5 MB / 59 B 1,276,214 / 39.4 MB / 59 B 1,211,100 / 39.5 MB / 59 B 1,208,699 / 39.5 MB / 24 B
30 x 100 4,534,061 / 39.5 MB / 60,360 B 5,684,573 / 39.5 MB / 60,360 B 5,737,044 / 39.5 MB / 60,360 B 1,967,451 / 39.5 MB / 2,460 B
200 x 2,000 363,326,457 / 40.9 MB / 8,408,900 B 510,157,272 / 123.8 MB / 8,408,900 B 525,006,531 / 126.2 MB / 8,408,900 B 18,633,084 / 40.8 MB / 50,900 B
4,000 x 4,000 14,701,829,132 / 838.9 MB / 375,222,890 B 20,529,849,089 / 4,033.8 MB / 375,222,890 B 21,090,670,277 / 4,035.9 MB / 375,222,890 B 225,734,198 / 42.9 MB / 106,890 B
  • The memory between 1.4.3 and 1.4.4 is the copy at each use that Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI #46021 added. Nobody reported it. The 4,000 x 4,000 row is synthetic.
  • --define AWS.ACCESS_KEY='"abc"' prints console.log("abc"); on 1.4.3, on 1.4.4 and on main. What one define per key cannot give: LIST[0], one flag for a whole JSON value, and AWS["ACCESS_KEY"] without --minify-syntax.

A read finds its member by a scan of the keys from the last one. So n reads of n keys cost about 4 times as much for each doubling (2.8 to 4.3 in the table). Memory does not grow that way.

n keys, n reads instructions peak RSS
2,000 58,364,418 41.0 MB
4,000 225,736,823 43.0 MB
8,000 956,495,319 46.3 MB
16,000 2,706,607,945 50.8 MB
32,000 11,679,333,789 60.1 MB
nested (DB.users.uN.name) 2,000 / 4,000 / 8,000 / 16,000 70,681,324 / 250,459,676 / 1,005,981,327 / 2,805,804,037 43.3 / 48.1 / 55.2 / 72.9 MB

bun build --compile --aot of n reads of an n-key define: 443,654,106,776 instructions and 6,315.1 MB on the base at 4,000 (1 run). Here: 1,460,894,358 / 99.2 MB at 2,000, 2,715,920,682 / 103.5 MB at 4,000, 5,672,722,798 / 135.2 MB at 8,000.

Run time: 2,000,000 calls of function f() { return CFG.key12; } with a 23-key define and BUN_JSC_useDFGJIT=0: 2,068,305,085 instructions on the base, 173,623,957 here. CFG[key] is 2,112 million on the base and 2,110 million here. With all tiers on the counts do not rank the builds: four repeats of the least of 3 runs give 70 to 78 million on the base and 31 to 64 million here, and the 3 runs of one repeat span up to 255 million.

Uses that still copy the whole value. AWS, AWS?.KEY, AWS[key], a computed key, a key that the value does not own, a call, a store, const { KEY } = AWS, and a chain that ends in one of these (CFG.list.map(f)). Each costs what it costs on main: the AWS?.KEY_n row.

AOT.

  • fold_member decides a member of a define value after the define table, so a define of the path itself still comes first.
  • A member that is an object or an array is decided only where the chain ends in it. Under a member access it is recorded, and the access decides.
  • fold_member now reads a key through a type assertion (CFG["a" as const], LIST[0 as number]), ahead of the define table. The transpiler has no node for an assertion. This also holds for a define of a path.
  • bun run reads the member out of a user's object where the build has a define of its own for the path (process.env.NODE_ENV). note_member_that_bun_run_reads records that where the two values are the same.
  • bun run has both["on"] as both.on. Where a user's define of that path exists, the model does not take the member of the object for what bun run has.
  • The text of a function (toString()) shows the member: text_replaced_by_bundling.rs handles a decided a[0] and leaves out the value of the object of a decided member.
  • A type inside a read that has a value in its place goes with the read ((AWS as any).KEY, AWS!.KEY, CFG["a" as const]): find_type_ranges_beside (src/sema/erasure/type_ranges.rs) no longer removes it a second time. On main the value is then dropped and the cut stays: (process as any).env.NODE_ENV with a define of that path prints (process;. Now it prints "production".
  • A type error at such a read, in its object or at its key is still reported. Decisions::is_part_of_define_member_read marks the object and the key of a read that fold_define_member decided, and the checker does not take them for dead code. A read in an arm that never runs stays unchecked, as before. A define of a path drops such an error on main. This PR leaves that as it is.
  • test/aot/minifying-first-saves-no-code/known-today.ts loses 4 entries that pass now (extra/DefineObject1, DefineObject2, DefineArray, DefineArray2).

Related PRs.

Self-review. Three rounds: 42 concerns on the first draft, 42 on the reworked diff, 40 on the diff after the second rework. 99 are addressed:

  • the stack on js_parser: fix string folds that read non-ASCII 8-bit text by byte or rewrite a write or delete target #47655 in place of two guards of this PR
  • the key as written in both front ends, and a key behind a type assertion
  • the whole value under every chain that takes no member (the first rework copied the nearest member, which a rule for {}.k ??= v and a rule for [a, b][n] then folded)
  • the frame and the hot path of visit_expr_in_out
  • --aot: function text behind a type assertion at the place where types and replaced text meet, type errors at a read with an arm that never runs left unchecked, a string member behind a type assertion, a define of the build beside a user's object, a key in brackets beside a define of its path
  • a define that keeps its whole value in a visit for a substitution, and an error at the name of the define that is reported once
  • tests: a mode matrix that includes output loaded again by Bun, write forms, roots with a dot and import.meta, a bound on memory, the dev server, specifiers, a key that is not ASCII and that the value owns, a number as the key of an object, arrows that end in a read behind a type
  • the docs and this text, with each fault of main that a replaced read reaches named above

20 are addressed in part. Most of them are faults of main that this text names and that this PR does not repair: numbers 8 to 16 above. The others: macro arguments and feature() are not pinned, macro objects keep their own rule, the second report of arguments under a chain of two links stays, and test/aot/parallel/bundler/define-object-member.ts guards values and passes on the base.

5 are not taken:

  • Decide the kind of a file from the file as written under bun run. That is another change to bun run. This PR follows the rule that a define of a path follows.
  • Repair --aot function text for a value longer than its read in this PR. The fault is on main for every define.
  • Hold the branch until a maintainer picks fold or shared variable. The PR states the choice in Background and says that no maintainer has ruled on it.
  • Shorten a test of js_parser: fix string folds that read non-ASCII 8-bit text by byte or rewrite a write or delete target #47655 (gives what the engine gives for each generated read) that takes 2 to 5 s in a debug build. It is not in this diff.
  • Test is_property_access_target in front of the call of leave_define_to_member_read. It was built and measured, and the numbers are under Measurements.

Suites run. Debug build with ASAN: the 5 changed test files. Release build of the branch: all of test/bundler (8,063 pass, the 2 bytecode cache portability failures are on the base), all of test/aot/parallel (7,169 of 7,185 pass, 12 skip, and the 4 that fail also fail on the base: packages/subpaths/exports-closed0, exports-closed6, module-loaders/raw-behind-a-path-asks-for-the-text-of-the-file, errors/call-sites-default-constructor-local-heat-shuffled.cjs), test/internal/source-lints. bun run rust:test for bun_sema, bun_sema_parser, bun_aot, bun_bundler. Each of the 5 changed test files fails on a release build of the base. A differential check outside the tree: 4,652 expressions over 6 define values in 7 modes against JSON.parse of the value. The rows that differ are the same 10 on the base and on this branch.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/bake/dev/hot.test.ts, test/aot/the-text-of-a-function-lacks-what-a-bundle-lacks.test.ts, test/aot/a-file-whose-only-require-a-define-removes-is-a-module.test.ts

… that member

With `--define AWS='{"ACCESS_KEY":"abc","SECRET_KEY":"def"}'`, Bun prints
`AWS.ACCESS_KEY` as `{ ACCESS_KEY: "abc", SECRET_KEY: "def" }.ACCESS_KEY`.
The visit of `AWS` puts a copy of the whole value in its place before the
member read sees it.

The visit of a member target that is an object or array define now leaves
the value to the member read. The read copies the one own member that it
names: `AWS.ACCESS_KEY`, `AWS["ACCESS_KEY"]`, `CFG.a.b`, `LIST[0]`,
`LIST.length`. The key is a name, or a string or a number that is written
as a literal. A member that is an object or an array goes to the member
read around it in the same way.

Every other use gets a copy of the whole value, as before: a key that the
value does not own, a computed key, `__proto__`, an optional chain, a
store, a `delete`, a call and a destructuring pattern. A chain that ends
in such a use takes no member, and the define under it puts the whole
value there. So does a define in a visit for a substitution.

A replaced read is what a define of that path is. `bun build` resolves
`require(CFG.path)`, and a file whose only `require` stands behind such a
read is an ES module under `bun run --define`.

`--compile --aot` decides the same reads in `fold_define_member`, from the
same keys. `fold_member` now reads a key through a type assertion, as the
transpiler does. The text of a function shows the member. A type inside a
part of the text that has a value in its place goes with that part. A type
error at such a read, in its object or at its key is still reported, and a
read in an arm that never runs stays unchecked.
@robobun

robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

CI

  • The checks of this PR pass: Format, Lint JavaScript, Source lints, cargo clippy, cargo miri test, mordant, comment-cop.
  • No test that this PR adds or changes failed on a lane of Buildkite build 136775, which has finished: Linux x64 and aarch64 (glibc and musl), ASAN, macOS x64 and aarch64, Windows x64 and aarch64.
  • That build is red because of tests that fail without this diff too. The build of the base PR (136690 for js_parser: fix string folds that read non-ASCII 8-bit text by byte or rewrite a write or delete target #47655) has the same red tests. They are AOT tests of mocks and of the engine (test/aot/*mock*, test/aot/engine/native-5.test.ts, native-6.test.ts, ir-1.test.ts), test/aot/parallel/module-loaders/raw-behind-a-path-asks-for-the-text-of-the-file/main.mjs and test/bundler/compile-aot-size.test.ts.
  • test/js/web/request/request-clone-leak.test.ts failed on the ASAN lane with an RSS delta of 64 MB against a limit of 64 MB. This PR does not touch that code.
  • I ran seven of the AOT tests on a release build of the base and of this branch. Each fails in the same way on both.

@robobun

robobun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:25 AM PT - Nov 2nd, 2026

❌ @robobun, your commit f4d8bec has 41 failures in Build #136775 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47712

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

bun-47712 --bun

@github-actions github-actions Bot added the claude label Nov 2, 2026

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

Beyond the inline findings, I also checked a few adjacent paths and found them consistent with the parser rule: process.env["NODE_ENV"] as a bracket read of an object define in src/sema/fold/define_members.rs (the is_user_defined_behind_index branch drops the user-defined flag so the build-added path define wins, as bun run does), an intermediate link of a chain like CFG.a.b (the entry recorded at line 139 is only consumed by the enclosing member read, which gives up on it at line 144), and the Function.prototype.toString text of a bracket member read in src/aot/lower/context/text_replaced_by_bundling.rs (the Index arm mirrors the Dot arm's bounds). Given the size of the parser and sema changes, a human look at the rest is still worthwhile.

Extended reasoning...

The change rewrites how the JS parser substitutes an object or array --define under a member read (src/js_parser/p.rs, visit_expr.rs, lib.rs) and mirrors that rule in the sema folder (src/sema/fold/define_members.rs and its callers) plus AOT lowering, with the runtime transpiler cache version bumped; it touches no auth, crypto or injection surface. Two confirmed findings are posted inline (bundler now resolves require(CFG.path) at build time, and an --aot module-format decision that can disagree with bun run when process.env is object-defined), and the diff is large (+1744/-36 across parser, sema and tests), so it is not approvable; this note only records the extra paths examined and ruled out.

Comment thread src/js_parser/p.rs Resolved
Comment thread src/sema/fold/define_members.rs Resolved
@robobun

robobun commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

Answers to the two inline findings of the review. I ran each command on a release build of the base (#47655) and of this branch.

require(CFG.driver) is resolved by bun build (read_member_of_define, src/js_parser/p.rs)

This is the change that the first bullet of Downsides states. A replaced read is what a define of that path is.

  • On the base, --define CFG.driver='"./pg-driver.js"' bundles the file of require(CFG.driver). So does --define DRIVER='"./pg-driver.js"' with require(DRIVER). A file that is not there fails with Could not resolve.
  • On the base, the object define leaves __require({ driver: "./pg-driver.js" }.driver). With this PR it gives what the other two defines give.
  • --external ./pg-driver.js leaves __require("./pg-driver.js") for run time. A read through a variable leaves it too. extra/DefineObjectMemberAsExternalModule pins both.
  • The error shows the read: Could not resolve: "./nope.js" with the caret under CFG.missing_file.

I did not add an exception for a read that is the argument of require or import(). With it, the whole object is printed at exactly those reads, and require(CFG.driver) differs from require(DRIVER). If a maintainer prefers the exception, I can add it to both front ends.

A process.env object whose NODE_ENV is not the value of the build (note_member_that_bun_run_reads, src/sema/fold/define_members.rs)

The PR notes list this as number 16 of the faults that a replaced read reaches. The values differ on the base, not only the kind of the file. Measured with --define process.env='{"NODE_ENV":"staging","X":1}', no NODE_ENV in the environment, and this file:

console.log(process.env.NODE_ENV);
if (process.env.NODE_ENV !== "staging") require("node:path");
base this PR
bun run staging, CommonJS staging, ES module
bun build --compile development, ES module development, ES module
bun build --compile --aot development, CommonJS development, CommonJS
  • bun build adds its own define of process.env.NODE_ENV, and a define of a path comes before a member of an object. bun run adds no such define. So the two read different values on the base, and they take different branches.
  • With the one-key object of the finding ({"NODE_ENV":"staging"}) the base has the same difference in kind: ES module under bun run, CommonJS in the --aot executable. bun run folds a read of a one-key object literal on main.
  • Where the two values are the same, note_member_that_bun_run_reads makes the kind agree.

To make the two agree where the values differ, bun build has to take the member of a user's object before its own define, or bun run has to add that define. Each changes the order of defines for every build. This PR does not do that.

… compiled once

`read_member_of_define` is compiled once for each parser type. The part
that reads the key of the member uses no parameter of those types. It is
now `defines::own_member_of_read`.
Comment thread src/js_parser/lib.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/js_parser/p.rs Resolved
Comment thread src/jsc/RuntimeTranspilerCache.rs Resolved
Comment thread src/sema/check/aot/compiled_file.rs Resolved
Comment thread src/sema/fold.rs Resolved
Comment thread src/sema/fold/dead_code.rs Resolved
Comment thread src/sema/fold/define_members.rs Resolved
Comment thread src/sema/fold/define_members.rs Resolved
Comment thread src/sema/fold/define_members.rs Resolved
Comment thread src/sema/fold/define_members.rs Resolved
Comment thread src/sema/fold/define_trie.rs Resolved
Each comment of two or more lines that the change added under `src/` is
now one line.

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

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

Still open from earlier reviews (2):

  • 🔴 src/js_parser/p.rs:7272 — Users of bun build with an object define now get a build failure or an unintentionally bundled module when a member of…
  • 🔴 src/sema/fold/define_members.rs:181 — Users who object-define process.env under --compile --aot can get a module format or dead-code decision that disagrees…

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

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.

1 participant