Skip to content

js_parser: keep all macro-object properties when the destructuring pattern reorders keys - #35574

Closed
robobun wants to merge 6 commits into
mainfrom
farm/d0852fb6/macro-destructure-key-order
Closed

robobun wants to merge 6 commits into
mainfrom
farm/d0852fb6/macro-destructure-key-order

Conversation

@robobun

@robobun robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #9613.

Repro

// macro.ts
export const obj = () => ({ a: "a", b: "b", c: "c" });
import { obj } from "./macro.ts" with { type: "macro" };
const { c, a, b } = obj();
console.log(a, b, c);   // undefined b c
$ bun build ./index.ts --target bun
const { c, a, b } = { c: "c", b: "b" };   // 'a' is gone

Cause

visit_binding_and_expr_for_macro filters the inlined object literal down to the properties the binding pattern actually uses. It did this by iterating the pattern keys, looking each one up in the object with as_property(), and writing properties[end] = properties[query.i] into the same array it was searching. When the pattern's key order differed from the object's, an early write overwrote an entry that a later lookup still needed, so that key was dropped from the emitted literal and the binding became undefined.

For { c, a, b } against { a, b, c }:

  • c matches index 2, write props[0] = props[2]; array is now [c, b, c]
  • a searches [c, b, c] and is not found
  • b matches index 1, write props[1] = props[1]
  • truncate to 2: { c, b }

Fix

Split into two passes. The first pass resolves every bound key against the unmodified property list (recursing into nested objects and recording const_values exactly as before) and marks which source indices are referenced in a SmallVec<[bool; 8]>. The second pass compacts forward in source order with slice::swap(end, i), matching the existing compaction pattern in scan_side_effects.rs and elsewhere in this file; no unsafe is needed.

Verification

test/bundler/transpiler/macro-destructure.test.ts runs the repro under both bun run and bun build, covering all six permutations of {a,b,c}, a reordered subset, an aliased key, the same key bound twice ({ a, a: a2 }), a missing key, and a nested { outer: { z, x } } pattern. Both tests fail on the released bun and pass with this change.

The tests live in their own file because macro-test.test.ts evaluates top-level macro imports at module load and can intermittently trip a pre-existing !m_topGCOwnedDataScope debug assertion before any test runs.


[review] gate passed · iteration 1 · 2 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-destructure.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/1277] fetch zlib
[zlib] up to date
[2/1277] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[3/1277] gen bindgenv2
[4/1277] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/debug/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[5/1277] gen JSBuffer.lut.h
Generating /workspace/bun/build/debug/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[6/1277] subst deps/zlib/zlib.h
[7/1277] fetch picohttpparser
[picohttpparser] up to date
[8/1277] subst deps/zlib/zconf.h
[9/1277] subst deps/libjpeg-turbo/jconfig.h
[10/1277] fetch nodejs (prebuilt)
[nodejs] up to date
[11/1277] subst deps/libjpeg-turbo/jconfigint.h
[12/1277] subst deps/libjpeg-turbo/jversion.h
[13/1277] fetch zstd
[zstd] up to date
[14/1277] gen ProcessBindingBuffer.lut.h
Generating /workspace/bun/build/debug/codegen/ProcessBindingBuffer.lut.h from /workspace/bun/src/jsc/bindings/P
... (truncated)

release without fix: 2 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/bundler/transpiler/macro-destructure.test.ts:
39 |     return { stdout, stderr, exitCode };
40 |   }
41 | 
42 |   test.concurrent("bun run", async () => {
43 |     using dir = tempDir("macro-destructure-run", files);
44 |     expect(await run([bunExe(), "index.ts"], String(dir))).toEqual({
                                                                ^
error: expect(received).toEqual(expected)

  {
    "exitCode": 0,
    "stderr": "",
    "stdout": 
- "["a,b,c","a,b,c","a,b,c","a,b,c","a,b,c","a,b,c","a,c","a,b","a,a","c,undefined","1,3"]
+ "["a,b,c","a,,c",",b,c",",b,c",",b,c",",b,c",",c",",b","a,a","c,undefined",",3"]
  "
  ,
  }

- Expected  - 1
+ Received  + 1

      at <anonymous> (/workspace/bun/test/bundler/transpiler/macro-destructure.test.ts:44:60)
(fail) destructuring a macro object > bun run [42.07ms]
50 | 
51 |   test.concurrent("bun build", async () => {
52 |     using dir = tempDir("macro-destructure-build", files);
53 |     const build = await run([bunExe(), "build", "./index.ts", "--target", "bun", "--outfile", "out.js"], String(dir));
54 |     expect(build).toMatchObject({ exitCode: 0 });
55 |     expect(
... (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-destructure.test.ts
bun test v1.4.0 (4dbed4248)

test/bundler/transpiler/macro-destructure.test.ts:
(pass) destructuring a macro object > bun run [732.53ms]
(pass) destructuring a macro object > bun build [1231.15ms]

 2 pass
 0 fail
 3 expect() calls
Ran 2 tests across 1 file. [5.92s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     4dbed4248e
  features     baseline

22 deps, 108 codegen, 1171 objects in 8637ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1234] gen ErrorCode+*.h
[2/1234] gen bindgenv2
[3/1234] fetch tinycc
[tinycc] up to date
[4/1234] gen ProcessBindingHTTPParser.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingHTTPParser.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingHTTPParser.cpp
[5/1234] fetch zlib
[zlib] up to date
[6/1234] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[7/1234] fetch picohttpparser
[picohttpparser] up to date
[8/1234] gen ProcessBindingFs.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingFs.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingFs.cpp
[9/1234] gen .bind.ts → GeneratedBindings.cpp
[10/1234] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[11/1234] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/buil
... (truncated)
diff hotspot
src/js_parser/visit/mod.rs                        | 25 ++++------
 test/bundler/transpiler/macro-destructure.test.ts | 61 +++++++++++++++++++++++
 2 files changed, 72 insertions(+), 14 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                               reads  edits  tests
src/js_parser/visit/mod.rs                             5      6      0
test/bundler/transpiler/macro-destructure.test.ts      0      1      0

…ttern reorders keys

The macro-destructuring visitor filtered the inlined object literal down
to the properties referenced by the binding pattern by iterating the
pattern and writing properties[end] = properties[query.i] in place. When
the pattern's key order differed from the object's, an early write could
overwrite an entry that a later as_property() lookup still needed to
find, so that key was dropped from the emitted literal and the binding
became undefined.

Split the loop into two passes: resolve every bound key against the
unmodified property list (recursing / recording const values as before)
and mark which source indices are referenced, then compact forward in
source order so end <= i always holds and no entry is read after being
overwritten.

Fixes #9613.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Macro destructuring

Layer / File(s) Summary
Property resolution and regression coverage
src/js_parser/visit/mod.rs, test/bundler/transpiler/macro-destructure.test.ts
Macro property references are tracked before in-place compaction, and direct plus bundled tests cover reordered, renamed, and missing-key destructuring.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address #9613 by preserving all destructured macro properties across key permutations and adding matching tests.
Out of Scope Changes check ✅ Passed The code and test changes are directly related to the destructuring bug fix, with no clear unrelated additions.
Title check ✅ Passed The title clearly summarizes the main fix: preserving macro object properties when destructuring order changes.
Description check ✅ Passed The PR description covers the bug, cause, fix, and verification, which satisfies the template content even though the headings differ.

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

@robobun

robobun commented Jul 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:16 AM PT - Jul 25th, 2026

@robobun, your commit 4dbed42 is building: #80824

Comment thread src/js_parser/visit/mod.rs Resolved

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/bundler/transpiler/macro-test.test.ts`:
- Around line 173-177: The subprocess assertions at
test/bundler/transpiler/macro-test.test.ts lines 173-177 and 185-189 should
compare the complete trimmed stdout to JSON.stringify(expected), while retaining
stderr and exitCode in the same combined result assertion; remove final-line
parsing so both transpiler and built-output tests enforce exact child output.
- Around line 149-162: Add a destructuring test case in the macro test matrix
that binds the same source key twice under different local names, using a
pattern equivalent to `{ a, a: a2 }` and asserting both values are emitted.
Append the corresponding `"a,a"` result to the `expected` array.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2e9976bf-4c29-46ea-81fe-73bfad56aff6

📥 Commits

Reviewing files that changed from the base of the PR and between df6c7ee and 97f55fc.

📒 Files selected for processing (2)
  • src/js_parser/visit/mod.rs
  • test/bundler/transpiler/macro-test.test.ts

@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 nit, I checked: query.i is bounded by object.properties.len() (it comes from enumerating that same slice in as_property), so used[query.i as usize] cannot index out of bounds; the copy_nonoverlapping is sound because it's gated on i != end and the forward pass guarantees end <= i. On CodeRabbit's "assert full stdout" suggestion — the .split("\n").pop() matches the neighboring sparse-key test in this file, which documents that debug builds print [macro] call <name> to stdout before script output, so asserting the full stdout would fail on debug CI.

Extended reasoning...

The two-pass mark-then-compact fix is correct and the SAFETY invariants hold; the only finding is a comment-style nit that overlaps comment-cop's existing flag. Deferring rather than approving because this is unsafe pointer arithmetic in the parser hot path and the comment-cop / style feedback hasn't been addressed yet. The note above records what was verified so a human reviewer doesn't re-derive it.

Comment thread src/js_parser/visit/mod.rs Resolved
Comment thread src/js_parser/visit/mod.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 comment/test-assertion feedback from the earlier pass is addressed in f1bc86c, and the remaining comment-cop firing on the 2-line invariant comment is a false positive.

What was reviewed:

  • Mark-then-compact: used is sized to object.properties.len(), query.i is bounded by as_property, and the compaction loop maintains end <= i with the i != end guard before copy_nonoverlapping.
  • Repeated-key binding ({ a, a: a2 }) now marks one source slot and emits one property — correct destructuring semantics; output order shifts to source order, which is immaterial for keyed destructuring.
  • Tests: all six permutations, subset, alias, duplicate key, missing key, nested pattern; both bun run and bun build paths; exact-stdout assertion with the debug [macro] prefix filtered.
Extended reasoning...

Overview

Two files: src/js_parser/visit/mod.rs (the visit_binding_and_expr_for_macro object arm, ~20 lines net) and test/bundler/transpiler/macro-test.test.ts (a new describe block with two concurrent subprocess tests). The fix replaces an in-place compact-during-lookup with a two-pass mark-then-compact so as_property always searches the unmodified property list.

Security risks

None. This is transpiler-side AST rewriting of a compile-time macro result; no untrusted-length arithmetic, no new external input surface. The unsafe block moves G::Property (no Drop) within an arena slice under a proven end <= i invariant, matching the pattern already used elsewhere in this file.

Level of scrutiny

Moderate — it touches parser/visitor code with an unsafe block, but the change is mechanical and the invariant is trivially provable. I verified as_property returns i as an enumeration index into properties.slice() (src/ast/e.rs:1302), so used[query.i as usize] cannot go out of bounds, and used.len() == object.properties.len() bounds the compaction loop. The switch from core::ptr::copy to copy_nonoverlapping is justified by the i != end guard.

Other factors

All prior review feedback is addressed: my earlier note about bug-history comments (mod.rs is now a 2-line invariant comment; the test has just the issue URL), and CodeRabbit's two notes (repeated-key case added; exact combined {stdout, stderr, exitCode} assertion instead of last-line JSON parsing). The comment-cop bot fired again on the trimmed 2-line comment, but that action flags any multi-line comment in claude PRs — the comment now states an invariant, not a workaround justification, which is exactly what REVIEW.md asks for. No CODEOWNERS on src/js_parser/. Test coverage is thorough for the variant matrix (permutations, subset, alias, duplicate key, missing key, nested) across both entry points.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

I opened #35619 for the same bug before spotting this one; closing mine in favour of this.

Two observations from that work, in case they are useful here:

  • macro-test.test.ts currently aborts under bun bd test with ASSERTION FAILED: !m_topGCOwnedDataScope while evaluating the top-level macro imports (pre-existing, unrelated to this change). The new tests in that file never run under a debug build as a result, so the fail-before / pass-after check won't exercise them. js_parser: swap instead of copy when compacting macro object properties #35619 put them in a standalone test/bundler/transpiler/macro-destructure.test.ts instead.
  • The single-pass alternative on the farm/66518298/macro-destructure-reorder branch swaps props[end] and props[query.i] rather than tracking a bitmap, which drops the unsafe block and matches the compaction pattern already used at scan_side_effects.rs:397 and visit/mod.rs:1546. Leaving that as a data point; either approach is correct.

@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 mark-then-compact split correctly fixes the in-place clobbering, and all prior review feedback has been addressed.

What was reviewed:

  • Verified query.i from as_property() indexes into object.properties, so used[query.i] is in bounds and the SAFETY invariant end <= i holds for copy_nonoverlapping.
  • Checked the sibling BArray arm — it truncates then zips in order, so it never had this bug.
  • Confirmed the second comment-cop message is stale (fired on f1bc86c; 3881d92 removed the comment it flagged).
Extended reasoning...

Overview

This PR fixes #9613: destructuring a macro-returned object with keys in a different order than the object literal dropped some bindings. The change is confined to visit_binding_and_expr_for_macro in src/js_parser/visit/mod.rs (~15 net lines) plus a new regression test block in test/bundler/transpiler/macro-test.test.ts.

Correctness

The root cause is well-explained: the old single-pass loop wrote properties[end] = properties[query.i] while still calling as_property() on the same slice, so an early write could overwrite an entry a later lookup needed. The fix splits this into (1) resolve every bound key against the unmodified list and mark used[query.i], then (2) compact forward in source order. I verified via src/ast/e.rs:1302 that query.i is the enumeration index into object.properties, so the SmallVec<[bool; 8]> sized to properties.len() cannot be indexed out of bounds. The compaction loop maintains end <= i, so the i != end guard makes copy_nonoverlapping sound; G::Property has no Drop (same assumption the old code made). The const_values recording and nested-object recursion still happen in pass 1 exactly as before. The BArray arm is unchanged and doesn't share the bug — it truncates to min(len) and iterates in lockstep, never searching.

Security risks

None. This is a compile-time AST rewrite of an already-materialized macro result; no user-controlled sizes drive allocation, and the only index (query.i) is bounded by construction.

Level of scrutiny

Moderate — it touches an unsafe block in the parser, but the unsafe was already there and the new version has a strictly stronger invariant (forward-only compaction vs. arbitrary-index copy). The change is localized to a niche macro-inlining optimization with no effect on non-macro code paths.

Other factors

All earlier review feedback is resolved: my prior note about bug-history comments (both trimmed — test now has only the issue URL; the mod.rs explanatory comment was dropped in 3881d92, leaving just the SAFETY line), CodeRabbit's request for a repeated-key case ({ a, a: a2 } added) and exact-stdout assertion (now compares the full {stdout, stderr, exitCode} object with the [macro] debug lines filtered). The second comment-cop message on the thread predates 3881d92 and no longer applies. Tests cover all six key-order permutations, a reordered subset, aliased/repeated/missing keys, and a nested pattern under both bun run and bun build. smallvec is already a dependency of this crate. No CODEOWNERS entry gates src/js_parser.

Matches the existing G::PropertyList compaction in scan_side_effects.rs
and the decl compaction earlier in this file; the second pass reads only
the used bitmap, never the properties themselves, so no raw-pointer
provenance is needed.
macro-test.test.ts evaluates top-level macro imports at module load,
which can trip a pre-existing !m_topGCOwnedDataScope debug assertion and
abort before any test runs. The destructuring tests are self-contained
subprocess tests, so isolating them keeps local debug verification
deterministic.

@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 — all prior feedback is addressed (tests moved to macro-destructure.test.ts, explanatory comment dropped, safe swap replaces the unsafe compaction, repeated-key and exact-stdout coverage added).

What was reviewed:

  • Two-pass mark-then-compact: first pass only reads object.properties via as_property and writes to a separate used bitmap, so lookups can't be clobbered; second pass maintains end <= i so swap(end, i) is a stable in-place partition.
  • { a, a: a2 } sets used[query.i] idempotently — the property is kept once and both bindings resolve.
  • Test file is hermetic, drains pipes concurrently, asserts the full {stdout, stderr, exitCode}, and the [macro] stdout filter matches the real debug-build prettyln! in src/js_parser_jsc/Macro.rs.
Extended reasoning...

Overview

Fixes #9613: destructuring a macro-returned object with keys in a different order than the object literal dropped properties. The change is ~15 net lines in src/js_parser/visit/mod.rs::visit_binding_and_expr_for_macro plus a new 61-line test file test/bundler/transpiler/macro-destructure.test.ts.

The old code compacted object.properties in place while still calling as_property() against it, so an early write could overwrite an entry a later lookup needed. The fix splits into (1) a lookup pass that marks referenced source indices in a SmallVec<[bool; 8]> without touching the property array, and (2) a forward compaction pass using safe slice_mut().swap(end, i) and truncate(end). The nested-object recursion and const_values recording are preserved verbatim in the first pass.

Security risks

None. This is AST-shaping logic that only runs when a macro's inlined object is destructured; no untrusted-length arithmetic, no allocation sized from external data, and the change removes an unsafe block rather than adding one.

Level of scrutiny

Moderate. js_parser is core, but this function is narrow (macro-object destructuring only) and the new logic is a textbook mark-then-compact with a trivially-verifiable end <= i invariant. I traced as_property (src/ast/e.rs:1302) to confirm it linearly scans self.properties.slice() — since pass 1 never mutates that slice, every lookup sees the original list. Compaction preserves source order, which is fine for destructuring semantics.

Other factors

This is my third look at the PR. Every piece of prior feedback — mine (comment verbosity, test-file placement), CodeRabbit's (repeated-key coverage, exact-stdout assertion), comment-cop's, and robobun's optional note about the unsafe block — has been addressed in commits f1bc86c, 3881d92, 3eb0e44, and 4dbed42. robobun's alternative single-pass-swap design was explicitly offered as "either approach is correct"; the two-pass chosen here is equally sound and the SmallVec<[bool; 8]> stays on the stack for typical objects. The new test file has no top-level macro imports, so it runs under bun bd test for local fail-before/pass-after verification, covers all six key permutations plus subset/alias/repeated/missing/nested, uses test.concurrent, and asserts exact combined subprocess output. No CODEOWNERS entry covers src/js_parser/.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

The diff is green: macro-destructure.test.ts passes on every lane. Across four CI runs (#80299, #80423, #80580, #80824) the only failures are a rotating set of unrelated flakes (bun-security-scanner-matrix-with-node-modules, inspect, require-cache, no-orphans, proxy, fetch-leak, 20144), none touching the parser or macro path. Ready for a maintainer to merge.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: this was fixed on main by #37669, which replaced the aliasing ptr::copy compaction in visit_binding_and_expr_for_macro with a swap-based one, so a destructuring pattern whose key order differs from the macro result no longer drops properties. The test file this PR adds (test/bundler/transpiler/macro-destructure.test.ts, covering every permutation plus the alias, repeated-key, missing-key and nested cases) passes unmodified against a debug build of main at 04148c8, twice in a row, and the repro from #9613 now prints a b c.

@robobun robobun closed this Aug 13, 2026
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.

Destructuring assignment from macro function fails

2 participants