Skip to content

parser: fold const values into macro arguments regardless of statement position - #34089

Closed
robobun wants to merge 8 commits into
mainfrom
farm/c1c590d7/macro-const-folding-scope-wide
Closed

robobun wants to merge 8 commits into
mainfrom
farm/c1c590d7/macro-const-folding-scope-wide

Conversation

@robobun

@robobun robobun commented Jul 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

The docs at docs/bundler/macros.md (Arguments section) promise that a macro argument may reference a const whose value is statically known, including the result of another macro, and show this as the worked example:

import { getText, getFoo } from "./macros.ts" with { type: "macro" };

const foo = getFoo();
const text = getText(`https://example.com/${foo}`);

That example has always failed with "Cannot convert argument type to JS" error in macro. Two related shapes expose the same class of bug:

// m.ts
export function f(x) { return x; }
export function getFoo() { return "foo"; }

// A) const N = 5; console.log(f(N));                      // ok: 5
// B) console.log("x"); const N = 5; console.log(f(N));    // error: Cannot convert identifier to JS
// C) const foo = getFoo(); console.log(f(foo));           // ok: foo
// D) const foo = getFoo(); f(`https://example.com/${foo}`)// error: Cannot convert argument type to JS

Whether a build succeeds depends on where an unrelated statement sits relative to a const, and template-literal arguments never accept a macro-derived substitution.

Cause

Three separate gaps:

  1. const_values (the map handle_identifier uses to substitute known values) was only populated for a scope's uninterrupted leading declaration run. visit_stmts_switch_one sets is_after_const_local_prefix = true for every statement and only restores it for inert/declaration statements, so a single console.log("x") before the const meant the identical macro call saw a bare identifier.

  2. handle_identifier only consulted const_values when features.inlining was enabled. Macro arguments are visited with should_fold_typescript_constant_expressions = true, but that flag was not checked, so bun build without --minify never substituted const identifiers into macro args at all (shapes A and C also fail there today).

  3. data_to_js had no ETemplate arm. Run::coerce encodes every macro-returned string as a UTF-16 EString, and Template::fold only merges UTF-8 parts, so a template containing a macro-derived (or non-ASCII) substitution stays an ETemplate and was rejected as Cannot convert argument type to JS.

Fix

  • visit_decl: when the file has macro refs, record const initialisers that pass can_be_const_value() even past the leading-declaration prefix. Visiting is top-down, so an entry is only visible to textually-later uses; is_after_const_local_prefix is still maintained exactly as before for the DCE pass and for files without macros.
  • handle_identifier: also substitute from const_values when should_fold_typescript_constant_expressions is set (macro / require / require.resolve args, enum member inits).
  • expr_jsc::data_to_js: handle untagged ETemplate by flattening head, part values (string / number / null / undefined / boolean / inlined enum / nested template) and tails into a single JS string. Tagged templates and unknown part types keep the existing errors.

Tests

Added to test/bundler/transpiler/macro-test.test.ts: each of the four shapes plus the in-function variant, a non-ASCII template substitution, a numeric template substitution, the combined post-statement + macro-result + template case, a let negative case, and bun build (no --minify) variants. Also un-commented the two template-string tests that were disabled pending this.

Fixes #16969


[review] gate passed · iteration 2 · 5 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/transpiler/macro-test.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (5e56d3603)

test/bundler/transpiler/macro-test.test.ts:
[macro] call escapeHTML
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call escape
[macro] call addStrings
[macro] call ad
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (a7c2912a3)

test/bundler/transpiler/macro-test.test.ts:
(pass) bun builtins can be used in macros [1.99ms]
(pass) latin1 string [0.04ms]
(pass) ascii string [0.01ms]
(pass) type coercion [0.07ms]
(pass) escaping [0.16ms]
(pass) utf16 string [0.01ms]
(pass) import aliases [0.04ms]
(pass) default import [0.01ms]
(pass) namespace import [0.02ms]
(pass) template string ascii
(pass) template string latin1
(pass) const folding into macro arguments > leading const [22.12ms]
(pass) const folding into macro arguments > const after an unrelated statement inside a function [14.89ms]
(pass) const folding into macro arguments > const after an unrelated statement [15.85ms]
(pass) const folding into macro arguments > const from macro result [14.10ms]
(pass) const folding into macro arguments > template literal with macro-result const [13.23ms]
(pass) const folding into macro arguments > template literal with non-ascii literal const [12.65ms]
(pass) const folding into macro arguments > template literal with numeric const [13.18ms]
(pass) const folding into macro arguments > template literal after an unrelated statement, with macro-result const [12.39ms]
(p
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/transpiler/macro-test.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (5e56d3603)

test/bundler/transpiler/macro-test.test.ts:
[macro] call escapeHTML
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call identity
[macro] call escape
[macro] call addStrings
[macro] call ad
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 656ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/29] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 243 extern-C blocks audited
[2/29] gen cpp.rs (cppbind)
[3/29] gen JS modules (bundle-modules)
Preprocess modules (6441ms)
Bundle modules (29ms)
Postprocesss modules (145ms)
Bundle Functions (668ms)
Generate Code (81ms)

[7.38s] Bundled "src/js" for production
  1912 kb
  162 internal modules
  12 native modules
  90 internal functions across 19 files
[3/21] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rus
... (truncated)
diff hotspot
src/js_parser/p.rs                         |   9 +-
 src/js_parser/visit/mod.rs                 |  54 ++++++++-
 src/js_parser/visit/visit_expr.rs          |   5 +
 src/js_parser_jsc/expr_jsc.rs              | 123 +++++++++++++++++++
 test/bundler/transpiler/macro-test.test.ts | 182 +++++++++++++++++++++++++++--
 5 files changed, 359 insertions(+), 14 deletions(-)

gate history · 4 passed · 0 rejected · iteration 2

evidence per changed file
file                                        reads  edits  tests
src/js_parser/p.rs                              2      4      0
src/js_parser/visit/mod.rs                      7      7      0
src/js_parser/visit/visit_expr.rs               6      2      0
src/js_parser_jsc/expr_jsc.rs                   1      3      0
test/bundler/transpiler/macro-test.test.ts      2      8      0

…ition

Macro arguments that reference a const whose value is statically known
(a literal, or the result of another macro) are documented to work, but
two separate limitations made the docs' own worked example fail:

- const_values was only populated for a scope's uninterrupted leading
  declaration run. Any non-declaration statement before the const (e.g.
  a console.log) flipped is_after_const_local_prefix and later consts
  were never recorded, so identity(N) after such a statement hit
  'Cannot convert identifier to JS'. When a file imports macros, now
  record const literal initialisers regardless of prefix position;
  visiting is top-down so entries are only visible to later uses.

- handle_identifier only consulted const_values when features.inlining
  was set. Macro args are visited with
  should_fold_typescript_constant_expressions set, so consult the map
  under that flag too. This also makes the bundler (where inlining is
  gated on minify_syntax) fold const identifiers passed to macros.

- data_to_js had no ETemplate arm, so a template literal that didn't
  fully collapse during visiting (e.g. because a substitution was a
  UTF-16 EString, which every macro-produced string is) hit 'Cannot
  convert argument type to JS'. Convert untagged templates by
  flattening head, part values and tails to a single JS string.
@robobun

robobun commented Jul 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:58 PM PT - Jul 13th, 2026

❌ @robobun, your commit 5e56d36 has 2 failures in Build #72579 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34089

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

bun-34089 --bun

@coderabbitai

coderabbitai Bot commented Jul 13, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 26 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: df5cda18-2ad5-428b-816b-c7f8e5f2d8b9

📥 Commits

Reviewing files that changed from the base of the PR and between eb0d38b and ac17fa0.

📒 Files selected for processing (5)
  • src/js_parser/p.rs
  • src/js_parser/visit/mod.rs
  • src/js_parser/visit/visit_expr.rs
  • src/js_parser_jsc/expr_jsc.rs
  • test/bundler/transpiler/macro-test.test.ts

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

@github-actions

Copy link
Copy Markdown
Contributor

Found 4 issues this PR may fix:

  1. Macros cannot use constant under certain circumstances #16969 - Macros cannot use constant under certain circumstances — directly reproduces Gap 1 & 2 (const after a statement + identifier not resolved without inlining flag)
  2. Cannot use macro return value in another macro's parameter(indirectly) #6865 - Cannot use macro return value in another macro's parameter (indirectly) — reproduces Gap 2 (const from macro result not folded into subsequent macro call)
  3. crash: support template literals in macros #18047 - crash: support template literals in macros — reproduces Gap 3 (missing ETemplate arm in data_to_js causes panic)
  4. type: "text" should be statically-known for macros #21261 - type: "text" should be statically-known for macros — reproduces Gap 2 (type: "text" import produces a const binding that isn't folded)

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #16969
Fixes #6865
Fixes #18047
Fixes #21261

🤖 Generated with Claude Code

@robobun

robobun commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

Verified the four suggested issues against this branch:

Added Fixes #16969 to the description.

The previous commit consulted const_values under
should_fold_typescript_constant_expressions, which is also set while
visiting import()/require()/require.resolve() arguments. That collapsed
intentionally-dynamic specifiers like `./a/${x}.js` into static paths
and broke bundler_allow_unresolved.test.ts.

Use a dedicated is_inside_macro_arguments flag instead, set only while
visiting the arguments of a macro call, so dynamic import/require
specifiers are left alone.

@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:1816 — The pre-existing (Self::ALLOW_MACROS && could_be_macro) branch inserts into const_values for let/var too, and this PR's new should_fold_typescript_constant_expressions gate in handle_identifier now reads those entries — so let x = getFive(); x = 10; identity(x) (where both are macros) silently compiles to identity(5) instead of failing with "Cannot convert identifier to JS". Gate the could_be_macro insert on was_const (already threaded through) so reassignable bindings never enter the map. The PR's "let bindings are still rejected" test uses a literal initializer, so could_be_macro is false and it doesn't cover this shape.

    Extended reasoning...

    What the bug is

    visit_decl inserts a binding into p.const_values under three disjuncts:

    if could_be_const_value || (Self::ALLOW_MACROS && could_be_macro) || track_for_macro_args {
        if let Some(val) = decl.value {
            if val.can_be_const_value() {
                self.const_values.put(id_ref, val).expect("oom");
            }
        }
    }

    The first disjunct is gated on was_const (via could_be_const_value = was_const && !is_after_const_local_prefix), and the third (track_for_macro_args, added by this PR) is also gated on was_const. But the middle one — Self::ALLOW_MACROS && could_be_macro — is not. could_be_macro is computed in visit_decls as prev_macro_call_count != self.macro_call_count, i.e. "the initializer contained a macro call", and is passed regardless of the declaration kind. So let x = getFive() (macro) puts x → 5 into const_values exactly like const x = getFive() would.

    Before this PR that was harmless in the plain bun run / bun build (no --minify) path: handle_identifier only consulted const_values when self.options.features.inlining was on. This PR changes that check to self.options.features.inlining || self.should_fold_typescript_constant_expressions, and visit_expr.rs's e_call arm sets should_fold_typescript_constant_expressions = true for the duration of visiting a macro call's arguments. The reassignable let entry that was previously inert is now read.

    Step-by-step proof

    // m.ts
    export function getFive() { return 5; }
    export function identity(x) { return x; }
    // entry.ts
    import { getFive, identity } from "./m.ts" with { type: "macro" };
    let x = getFive();
    x = 10;
    console.log(identity(x));
    1. visit_stmt for let x = getFive() computes was_const = (kind == KConst) = false and calls visit_decls.
    2. visit_decls records prev_macro_call_count, visits the initializer (macro expands to ENumber(5), macro_call_count increments), then calls visit_decl(..., was_const=false, is_after=..., could_be_macro=true).
    3. In visit_decl: could_be_const_value = false && ... = false; track_for_macro_args = ... && was_const && ... = false; but (Self::ALLOW_MACROS && could_be_macro) = true. ENumber passes can_be_const_value(), so const_values[x] = 5.
    4. x = 10; is visited outside any macro-arg context; the flag is false and inlining is off, so the assignment is emitted normally (nothing removes or updates the const_values entry).
    5. identity(x) is a macro ref, so e_call sets should_fold_typescript_constant_expressions = true and visits the args. handle_identifier for x now takes the new branch (p.rs:1816), finds x → 5 in const_values, and returns ENumber(5).
    6. The macro receives 5, and the file compiles to console.log(5) — printing 5 instead of erroring. The reassignment to 10 is silently ignored.

    Before this PR the same code failed at build time with error: cannot coerce EIdentifier to a JS runtime value ("Cannot convert identifier to JS") — the correct behavior, since x is reassignable and not statically known. The PR turns that build-time error into silently wrong output.

    Why the PR's own negative test doesn't catch it

    The "let bindings are still rejected" test uses let N = 5; — a literal initializer. could_be_macro is therefore false (no macro call in the initializer), and track_for_macro_args is gated on was_const, so N never enters const_values and the test correctly errors. The test needs a variant where the let initializer is itself a macro call.

    How to fix

    Since this PR already threads was_const into visit_decl, the one-line fix is to gate the middle disjunct on it too:

    if could_be_const_value
        || (Self::ALLOW_MACROS && was_const && could_be_macro)
        || track_for_macro_args

    (and mirror the same was_const in the is_after_const_local_prefix update just below, so a let x = macroCall() still ends the leading-const prefix). Alternatively, handle_identifier could check the symbol's kind before substituting, but gating the insert is more direct and matches the intent — const_values should only ever contain non-reassignable bindings.

  • 🟡 src/js_parser/p.rs:1816 — The PR description says should_fold_typescript_constant_expressions is only raised for macro/require/enum-init args, but it's actually set file-wide under minify_syntax || inlining (parse_entry.rs:568/757), and a pre-existing typo at visit_expr.rs:1818 (let prev_… = true; instead of saving the actual value) leaves it stuck at true after any dynamic import(). Combined with track_for_macro_args populating const_values for every const in a macro-importing file, this now inlines const literals at identifier sites well outside macro arguments. Output is still semantically correct (only immutable primitives pass can_be_const_value()), so not blocking — but since this PR now leans on that flag's save/restore, the one-line fix at visit_expr.rs:1818 (= p.should_fold_typescript_constant_expressions) is worth including here.

    Extended reasoning...

    What

    The new guard in handle_identifier:

    if self.options.features.inlining || self.should_fold_typescript_constant_expressions {
        if let Some(replacement) = self.const_values.get(&ref_) { … self.ignore_usage(ref_); … }
    }

    is described in the PR as firing only inside "macro / require / require.resolve args, enum member inits" — the positions where e_call temporarily raises should_fold_typescript_constant_expressions. In practice the flag is true in two additional situations:

    1. File-wide under minify_syntax. parse_entry.rs:568-569 and 757-758 set p.should_fold_typescript_constant_expressions = true for the whole file whenever minify_syntax || inlining. Bun.Transpiler({ minify: { syntax: true } }) sets features.minify_syntax = true while features.inlining stays false (it comes from the separate inline option, transpiler.rs:1621). So after this PR, that config starts substituting leading-prefix const literals at every identifier site — previously this was gated solely on features.inlining.

    2. After any dynamic import(), permanently. visit_expr.rs:1818 reads:

      let prev_should_fold_typescript_constant_expressions = true;
      p.should_fold_typescript_constant_expressions = true;

      The "restore" at 1846-1847 / 1851-1852 therefore always writes true back, regardless of the prior value. Compare with e_call at ~1958, which correctly saves the actual field. This is a pre-existing bug, but before this PR the leaked flag only affected numeric/template constant folding; now it also drives const-identifier substitution.

    Step-by-step (path 2, plain bun run, no minify)

    import { f } from "./m.ts" with { type: "macro" };
    const p = import("./lazy.js");
    const N = 5;
    console.log(N);
    • minify_syntax = false, inlining = false ⇒ flag starts false (parse_entry.rs:568).
    • File has a macro import ⇒ track_for_macro_args is true for every const, so const_values[N] = 5 is recorded even though N is past the leading prefix.
    • Visiting import("./lazy.js") enters e_import, which sets the flag to true and "restores" it to the hardcoded prev = true.
    • Visiting console.log(N) reaches handle_identifier with the flag now true ⇒ N is replaced by 5 and ignore_usage(N) is called — outside any macro-argument position, in a mode where the user asked for neither minification nor inlining.

    Why this doesn't break anything

    can_be_const_value() only admits primitives (number/string/boolean/null/undefined/bigint), inlined enums, and macro-frozen arrays/objects — all documented as "no observable difference if duplicated". The declaration-removal DCE loop only runs under minify_syntax and only in nested scopes, so no exported/module-level decl is dropped in the no-minify scenario. The emitted JS is semantically equivalent; this is a scope-drift / output-shape change, not a correctness bug. For path (1) specifically, the file-wide enablement under minify_syntax is deliberate per the comment in parse_entry.rs, and it aligns Bun.Transpiler({minify:{syntax:true}}) with bun build --minify-syntax (which already ties inlining = minify_syntax in ParseTask.rs).

    Addressing the counter-argument

    One could argue this is purely "PR description understates scope" and not worth a comment. For path (1) I'd agree — the extra inlining under minify.syntax is arguably desirable and matches the bundler. But path (2) is a genuine pre-existing save/restore bug (prev = true is clearly a typo for prev = p.should_fold_typescript_constant_expressions), and this PR increases what that leak affects from "a bit of extra constant folding" to "every tracked const in a macro-importing file gets inlined after the first dynamic import". Since the new gating in handle_identifier now depends on that flag's save/restore being correct, fixing it in the same PR is cheap and directly relevant.

    Suggested fix

    At src/js_parser/visit/visit_expr.rs:1818:

    let prev_should_fold_typescript_constant_expressions =
        p.should_fold_typescript_constant_expressions;

    Optionally, if you want the handle_identifier substitution to be strictly limited to macro/require/enum-arg positions as described, guard on a dedicated flag that only e_call raises rather than the file-wide should_fold_typescript_constant_expressions — but given the output is correct either way, that's your call.

Comment thread test/bundler/transpiler/macro.ts Outdated
The could_be_macro disjunct in visit_decl inserted into const_values
regardless of the declaration kind, so 'let x = macroCall()' recorded a
value that handle_identifier would then substitute in macro arguments
(and, with inlining enabled, on the left-hand side of a later
assignment). Gate both the identifier and destructuring paths on
was_const so reassignable bindings never enter the map.

Also drop the unused getText export from the fixture; the subprocess
tests carry their own m.ts.
@robobun

robobun commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed review findings in ac17fa0:

  • let x = macroCall() entering const_values: gated both the identifier and destructuring could_be_macro paths on was_const, so reassignable bindings never enter the map. Added let bindings with a macro initialiser are still rejected to cover this shape; it now errors with Cannot convert identifier to JS under both bun run and bun build.
  • should_fold_typescript_constant_expressions scope / e_import save typo: the earlier push (7640243) already switched handle_identifier off that flag and onto a dedicated is_inside_macro_arguments that is only raised in e_call for macro refs, so the file-wide enablement and the prev = true leak no longer drive const-identifier substitution. I left the typo at visit_expr.rs:1818 alone since this PR no longer depends on that flag's restore.
  • Unused getText in macro.ts: removed.

Comment thread src/js_parser/visit/mod.rs
robobun and others added 3 commits July 13, 2026 23:12
track_for_macro_args records past-prefix consts in const_values for
files with macro imports; under --minify-syntax handle_identifier then
substitutes every use, dropping use_count_estimate to 0. The 'remove
inlined constants' pass only walks the leading declaration prefix and
the single-use inliner required use_count_estimate == 1, so the now-dead
declaration was left in minified output.

Teach the single-use inliner to also drop the trailing decl when the ref
is in const_values and the use count is 0; presence in const_values
already implies the binding was const with a side-effect-free
initialiser.
@robobun

robobun commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff's own tests (test/bundler/transpiler/macro-test.test.ts, test/bundler/bundler_allow_unresolved.test.ts) are green on all lanes across builds 72569 and 72579. The remaining red is unrelated to this change:

  • test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js: pre-existing JSC getOwnPropertyDescriptor assertion on the x64-asan lane (also red on main).
  • test/js/bun/spawn/spawn.test.ts, test/js/web/fetch/fetch-leak.test.ts: flaky on Windows aarch64 (passed on retry).
  • test/cli/install/migration/complex-workspace.test.ts, test/cli/install/bun-install-registry.test.ts: flaky install tests (passed on retry).
  • test/js/node/test/parallel/test-fs-promises-file-handle-readFile.js: flaky FileHandle GC on x64-asan (passed on retry).

This is ready for review.

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

No new issues found — both earlier findings (dead getText fixture, the --minify past-prefix DCE regression) are addressed. Deferring to a human because this is a semantic change to the parser's const-folding / DCE passes and adds a new AST→JS conversion path.

Checked this run:

  • is_inside_macro_arguments save/restore is balanced and scoped only to is_macro_ref calls; import()/require() specifiers are unaffected (regression test covers it).
  • is_after_const_local_prefix maintenance is preserved for the DCE pass — track_for_macro_args does not suppress the flag; the only behaviour delta is that let x = macroCall() now ends the prefix (previously didn't), which is a correction.
  • The new zero-use drop in the single-use inliner is safe: entries reach const_values only via was_const + can_be_const_value(), so the initialiser is side-effect-free and the KVar/is_using() guards above still apply.
  • template_to_js: tagged templates and raw-cooked parts still error; recursion is stack-checked.
Extended reasoning...

Overview

Three coordinated changes across the parser and JSC bridge: (1) a new is_inside_macro_arguments flag on P so handle_identifier substitutes from const_values inside macro args without --minify; (2) visit_decl now records const literals into const_values past the leading-declaration prefix whenever the file has macro imports, plus a matching zero-use drop in the single-use inliner so those entries don't leave dead decls under --minify; (3) an ETemplate arm in data_to_js that flattens untagged templates with statically-known parts into a UTF-16 JS string. ~180 lines of new tests cover run/build/build-minify and negative let cases.

Security risks

None. No untrusted-input parsing surface is widened — template_to_js only handles AST nodes the parser already produced, and rejects anything it can't stringify with the existing ToJSError variants. No new FFI, no allocation-size arithmetic on external data.

Level of scrutiny

High. src/js_parser/visit/mod.rs is on the hot path for every file Bun parses, and the change touches two interacting optimisation passes (const-value tracking and the single-use inliner) plus the is_after_const_local_prefix state machine that esbuild-derived DCE depends on. The visit_decl signature change and the split of the else-branch into an independent if !could_be_const_value && !track_macro_result subtly changes when the prefix flag is set for non-const macro-initialised bindings; I traced it and believe it's correct (and arguably a fix), but this is exactly the kind of coupling a maintainer should eyeball.

Other factors

  • Two prior review rounds from me were addressed (dead fixture export removed in ac17fa0; DCE regression fixed in 09d556e with a --minify-syntax test).
  • CI is green on the diff's own tests across all lanes; the one red (test-worker-message-port-transfer-terminate.js on x64-asan) is pre-existing on main.
  • I looked for TDZ hazards from populating const_values past the prefix: visiting is textual-order and function bodies are visited at their declaration site, so an entry is only visible to later-visited code. A hoisted function called before the const in source would already be a runtime TDZ error, so macro-time substitution there doesn't introduce a new wrong-answer case.
  • The number-in-template path uses FormatDouble::dtoa; I did not verify it matches JS ToString for every edge (e.g. -0), but the only test exercising it is an integer.

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author

Correction to the earlier #6865 assessment: that issue is specifically about Bun.build() / bun build (not bun run), and it still reproduces on main without this PR:

$ bun build entry.ts
8 | const styles = create({
                   ^
error: "Cannot convert identifier to JS. Try a statically-known value" error in macro

Merged this branch onto main 028f7a3 and verified bun build entry.ts and Bun.build({ entrypoints: ["./entry.ts"] }) now both succeed with the expected output. The nested-object-property shape (create({ test: { animationName: test } }) where test is a prior macro's result) is covered by the is_inside_macro_arguments substitution because argument visiting is recursive.

Adding Fixes #6865 to the description would be accurate.

alii pushed a commit that referenced this pull request Aug 15, 2026
### Problem
- `visit_decl` in `src/js_parser/visit/mod.rs` took three adjacent
`bool` parameters (`was_anonymous_named_expr`, `could_be_const_value`,
`could_be_macro`), and the second of its two callers passed them
positionally: `visit_decl(decl, false, was_const && !is_after, false)`.
Nothing at that call says which flag is which.
- This is the one `bare_bool_args` finding recorded for this file in
`mordant-baseline.toml`.
- The shape has already bitten once: the Zig version of this call site
passed `was_const and !is_after` in the `was_anonymous_named_expr` slot
(`src/js_parser/visit/visit.zig` before d451445); the Rust port put
it in the right slot. Two open PRs (#34089, #35548) each add a fourth
positional bool to the same call.

### Fix
- Adds `VisitDeclOpts` to `src/js_parser/parser.rs`, next to
`VisitArgsOpts`, with one documented field per former parameter. Same
shape as the existing `VisitArgsOpts` / `ParenExprOpts` option structs;
an options struct rather than three enums because
`was_anonymous_named_expr` is handed straight on to
`maybe_keep_expr_symbol_name` as a `bool`, and `dylint.toml` already
treats several-bool structs as this repo's option-bag style.
- `visit_decl` now takes `opts: VisitDeclOpts` and destructures it on
entry; the rest of its body is unchanged. Both callers build the struct
with every field named, using the same expressions as before, in the
same order.
- Removes the `"bare_bool_args:src/js_parser/visit/mod.rs" = 1` line
from `mordant-baseline.toml`. Running the baseline writer scoped to
`bun_js_parser` (`MORDANT_BASELINE_WRITE=1 cargo dylint --all -p
bun_js_parser`) rewrites the file byte for byte identical to what is
committed.
- No behavior change is intended. Verified with a debug build:
- `bun bd test test/bundler/transpiler/`: 3870 pass, 0 fail. The only
red was `jsx-production.test.ts`, whose 32 concurrent parent+child
debug-build spawns exceed the 5s per-test timeout on an 8 core box; all
32 pass with `--timeout 120000`.
- `bun bd test test/bundler/bundler_minify.test.ts
test/bundler/esbuild/dce.test.ts test/regression/issue/26360.test.ts
test/regression/issue/22656.test.ts` (keepNames, const inlining,
macros): 128 pass, 0 fail.
- `bun bd test test/bundler/bundler_edgecase.test.ts`: 134 pass, 0 fail.
- `cargo dylint --all -p bun_js_parser -- --keep-going` with the
baseline line removed: on the previous code it reports the finding
quoted below as over the baseline; on this branch it reports nothing and
`target/mordant/over-baseline.txt` is not written.
- No test is added: a signature refactor has no observable behavior to
pin, so there is nothing a new test could fail on before this change.
The existing suites above are the coverage; the lint run is what
distinguishes before from after.

### Background
- `visit_decls` visits every `decl` of a `let`/`const`/`var` statement.
For each one it visits the initializer and then calls `visit_decl`,
which does the bookkeeping that depends on facts only the caller
observed: whether the initializer was an anonymous function or class
before visiting (so the binding's name can be attached to it via
`maybe_keep_expr_symbol_name`), whether this is a `const` still inside
the scope's leading run of `const`s (so the value may be recorded in
`const_values` for inlining), and whether visiting the initializer ran a
macro (so the macro result may be recorded or destructured into
`const_values`). The three fields of `VisitDeclOpts` are those three
facts.
- The second caller runs on the `exports.replace` / `exports.eliminate`
path of `Bun.Transpiler` for a declaration with no initializer. Its
replace/delete branches are inverted today (`eliminate` on `export let
x;` hits an `unwrap` on `None`); that is pre-existing and already
addressed by #33378, so it is left alone here.
- `mordant-baseline.toml` is a ratchet: it records the per (lint, file)
counts of findings that predate the lint job, and the job fails only on
findings above those counts. Fixing a finding means deleting its line so
it cannot come back.

<details>
<summary>Lint output on the previous code with the baseline line
removed</summary>

```
warning: `visit_decl` takes bools `was_anonymous_named_expr`, `could_be_const_value` and `could_be_macro`, and 1 of its 2 calls passes bare `true`/`false` for at least two of them. At `visit_decl(.., false, .., false)` nothing says which is which
   --> src/js_parser/visit/mod.rs:517:19
    |
517 |     pub(crate) fn visit_decl(
    |                   ^^^^^^^^^^
    |
note: one of those calls
   --> src/js_parser/visit/mod.rs:417:29
    |
417 | ...                   self.visit_decl(decl, false, was_const && !is_after, false);
    |                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    = help: give `was_anonymous_named_expr`, `could_be_const_value` and `could_be_macro` a two-variant enum each, or an options struct, so every call names what it sets
    = note: `bare_bool_args` over the mordant baseline (0 recorded for src/js_parser/visit/mod.rs)

warning: mordant: 1 finding(s) over the baseline in bun_js_parser
```

On this branch the same command finishes with no warnings.
</details>
@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-13, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. The linked issue (#16969) stays open. 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.

Macros cannot use constant under certain circumstances

1 participant