Skip to content

bundler: make --drop match bound identifiers (imports/locals), not just free globals#34985

Open
robobun wants to merge 3 commits into
mainfrom
farm/60fdeed4/drop-bound-identifiers
Open

bundler: make --drop match bound identifiers (imports/locals), not just free globals#34985
robobun wants to merge 3 commits into
mainfrom
farm/60fdeed4/drop-bound-identifiers

Conversation

@robobun

@robobun robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Reproduction

// log.ts
export const devlog = (...a) => console.log("DEVLOG", ...a);

// app.ts
import { devlog } from "./log";
globalThis.gfn = (s) => console.log("GFN", s);
devlog("secret");
gfn("free");
console.log("KEEP");
$ bun build app.ts --drop=devlog --drop=gfn | grep -E 'devlog\(|gfn\('
devlog("secret");          # imported binding survives
                           # gfn("free") was dropped (unbound)

$ bun --drop=devlog --drop=gfn app.ts
DEVLOG secret
KEEP

The docs describe drop: ["anyIdentifier.or.propertyAccess"], but only calls through an unbound identifier were removed. Calls through an import, a local function/const, or a namespace import (ns.method(...) with drop: ["ns"] or drop: ["ns.method"]) all survived without warning.

Cause

--drop entries are stored in the define table with method_call_must_be_replaced_with_undefined set. e_identifier only consults that table when the symbol's kind is Unbound:

// src/js_parser/visit/visit_expr.rs
if p.symbols[e_.ref_.inner_index() as usize].kind == js_ast::symbol::Kind::Unbound { ... }

and is_dot_define_match applies the same Unbound-only check to the root of a dotted define. Imported bindings are Import, local function declarations are HoistedFunction, const is Constant, so the drop flag is never reached for them. When the target was an import, handle_identifier also rewrites it to EImportIdentifier, which the e_call drop path did not recognize as a removable target.

Fix

  • e_identifier: for bound symbols that are being visited as a call target, consult the drop flag on the matching define entry. Value substitution for --define stays unbound-only.
  • is_dot_define_match: add allow_bound_root, set to true only for drop entries so --drop=a.b matches when a is a local or imported binding.
  • e_call: accept EImportIdentifier as a droppable target, and ignore_usage on the target chain when a call is dropped so the now-unused import/local can tree-shake.

After the fix:

$ bun build app.ts --drop=devlog --drop=gfn
// app.ts
globalThis.gfn = (s) => console.log("GFN", s);
console.log("KEEP");

$ bun --drop=devlog --drop=gfn app.ts
KEEP

Verification

$ USE_SYSTEM_BUN=1 bun test test/bundler/bundler_drop.test.ts
 16 pass
 6 fail   # ImportedIdentifierCall, ImportedDefaultCall, LocalFunctionCall,
          # LocalConstCall, ImportedNamespaceRoot, BoundDotDefine

$ bun bd test test/bundler/bundler_drop.test.ts
 22 pass
 0 fail

Also green: bundler_edgecase (116), esbuild/dce (73), bundler_minify (48), bundler_regressions, transpiler/transpiler.test.js (182).


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

fails on main (without fix)
ASAN without fix: 6 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_drop.test.ts
bun test v1.4.0 (de9efca41)

test/bundler/bundler_drop.test.ts:
(pass) bundler > drop/FunctionCall [911.15ms]
(pass) bundler > drop/DebuggerStmt [108.38ms]
(pass) bundler > drop/NoDisableDebugger [115.66ms]
(pass) bundler > drop/RemovesSideEffects [515.09ms]
(pass) bundler > drop/ReassignKeepsOutput [509.60ms]
(pass) bundler > drop/AssignKeepsOutput [525.58ms]
(pass) bundler > drop/UnaryExpression [494.86ms]
(pass) bundler > drop/0Args [497.26ms]
(pass) bundler > drop/BecomesUndefined [543.38ms]
(pass) bundler > drop/BecomesUndefinedNested1 [534.12ms]
(pass) bundler > drop/BecomesUndefinedNested2 [544.91ms]
(pass) bundler > drop/AssignTarget [517.60ms]
(pass) bundler > drop/DeleteAssignTarget [845.26ms]
(pass) bundler > drop/IdentifierCall [608.84ms]
125 |     },
126 |     run: { stdout: "KEEP" },
127 |     drop: ["devlog"],
128 |     backend: "api",
129 |     onAfterBundle(api) {
130 |       api.expectFile("out.js").not.toInclude(`"secret"`);
                                         ^
error: expect(re
... (truncated)

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

test/bundler/bundler_drop.test.ts:
(pass) bundler > drop/FunctionCall [31.63ms]
(pass) bundler > drop/DebuggerStmt [4.60ms]
(pass) bundler > drop/NoDisableDebugger [3.49ms]
(pass) bundler > drop/RemovesSideEffects [18.88ms]
(pass) bundler > drop/ReassignKeepsOutput [16.28ms]
(pass) bundler > drop/AssignKeepsOutput [15.45ms]
(pass) bundler > drop/UnaryExpression [14.64ms]
(pass) bundler > drop/0Args [14.73ms]
(pass) bundler > drop/BecomesUndefined [16.38ms]
(pass) bundler > drop/BecomesUndefinedNested1 [16.60ms]
(pass) bundler > drop/BecomesUndefinedNested2 [17.50ms]
(pass) bundler > drop/AssignTarget [16.01ms]
(pass) bundler > drop/DeleteAssignTarget [16.46ms]
(pass) bundler > drop/IdentifierCall [15.15ms]
(pass) bundler > drop/ImportedIdentifierCall [30.62ms]
(pass) bundler > drop/ImportedDefaultCall [28.12ms]
(pass) bundler > drop/LocalFunctionCall [28.78ms]
(pass) bundler > drop/LocalConstCall [28.29ms]
(pass) bundler > drop/ImportedNamespaceRoot [30.25ms]
(pass) bundler > drop/BoundDotDefine [30.52ms]
(pass) bundler > drop/BoundIdentifierNonCallNotReplaced [28.50ms]
(pass) bundler > drop/BoundDotNonCallNotReplaced [32.62ms]

... (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/bundler_drop.test.ts
bun test v1.4.0 (de9efca41)

test/bundler/bundler_drop.test.ts:
(pass) bundler > drop/FunctionCall [1230.28ms]
(pass) bundler > drop/DebuggerStmt [130.03ms]
(pass) bundler > drop/NoDisableDebugger [113.16ms]
(pass) bundler > drop/RemovesSideEffects [609.99ms]
(pass) bundler > drop/ReassignKeepsOutput [638.76ms]
(pass) bundler > drop/AssignKeepsOutput [506.70ms]
(pass) bundler > drop/UnaryExpression [485.50ms]
(pass) bundler > drop/0Args [502.65ms]
(pass) bundler > drop/BecomesUndefined [533.38ms]
(pass) bundler > drop/BecomesUndefinedNested1 [574.02ms]
(pass) bundler > drop/BecomesUndefinedNested2 [555.81ms]
(pass) bundler > drop/AssignTarget [919.47ms]
(pass) bundler > drop/DeleteAssignTarget [576.01ms]
(pass) bundler > drop/IdentifierCall [531.01ms]
(pass) bundler > drop/ImportedIdentifierCall [1337.95ms]
(pass) bundler > drop/ImportedDefaultCall [1587.30ms]
(pass) bundler > drop/LocalFunctionCall [1490.71ms]
(pass) bundler > drop/LocalConstCall [1510.09ms]
(pass) bundler > drop/ImportedNamespaceRoot
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 767ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_output v0.0.0 (/workspace/bun/src/output)
�[1m�[92m   Compiling�[0m bun_clap v0.0.0 (/workspace/bun/src/clap)
�[1m�[92m   Compiling�[0m bun_valkey v0
... (truncated)
diff hotspot
src/js_parser/p.rs                |  22 +++++--
 src/js_parser/visit/visit_expr.rs |  27 +++++++-
 test/bundler/bundler_drop.test.ts | 128 ++++++++++++++++++++++++++++++++++++++
 3 files changed, 170 insertions(+), 7 deletions(-)

gate history · 3 passed · 0 rejected · iteration 2

evidence per changed file
file                               reads  edits  tests
src/js_parser/p.rs                     3      2      0
src/js_parser/visit/visit_expr.rs      7      5      0
test/bundler/bundler_drop.test.ts      1      2      0

…st free globals

`--drop=<name>` piggybacks on the define table and inherited its
unbound-only gate, so calls to an imported or locally-declared function
survived while the same name as a free global was dropped:

  import { devlog } from './log';
  devlog('secret');    // kept
  gfn('free');         // dropped (unbound)

- e_identifier: consult the drop flag for bound symbols too (define
  substitution stays unbound-only)
- e_call: accept EImportIdentifier as a droppable target and ignore the
  target's recorded usage so the import can tree-shake
- is_dot_define_match: take allow_bound_root so --drop=a.b matches when
  a is a local/imported binding
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next review available in: 24 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: d9560c0e-6801-4e7d-b829-24bc9db076e8

📥 Commits

Reviewing files that changed from the base of the PR and between 98fb0ac and de9efca.

📒 Files selected for processing (3)
  • src/js_parser/p.rs
  • src/js_parser/visit/visit_expr.rs
  • test/bundler/bundler_drop.test.ts

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

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Status: diff is green. bundler_drop.test.ts passes on every lane; gate passed (fail-before + pass-after on ASAN and release). Ready for review.

CI: two consecutive builds each tripped a single unrelated test on one lane, and neither recurred across builds.

  • #77026: bun-server.test.ts websocket idle-CPU threshold on darwin-14-aarch64 (load-sensitive; did not recur on 77104).
  • #77104: test-http2-reset-flood.js ExceptionScope::assertNoException abort on debian-13 x64-asan (http2 runtime, not parser), plus the darwin-14-aarch64-test-bun job expired in the queue without running.

Both test failures are in subsystems this PR does not touch (src/js_parser/ only) and have been forwarded to main-break triage. All other annotations are flaky-passed-on-retry.

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:58 PM PT - Jul 21st, 2026

@robobun, your commit de9efca has 1 failures in Build #77104 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34985

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

bun-34985 --bun

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

I didn't find bugs, but this is a user-facing semantic change to what --drop strips from bundles (now matching bound imports/locals in addition to free globals), threaded through core parser paths (is_dot_define_match signature, e_identifier, e_dot, e_call). Worth a human look to confirm the intended scope.

What was reviewed:

  • All is_dot_define_match call sites updated; import.meta path kept at allow_bound_root=false.
  • New bound-identifier branch in e_identifier is gated on the call-target flag + assign_target == None + !is_delete_target, and only reads the drop flag — --define value substitution stays unbound-only (covered by DefineDoesNotReplaceBoundIdentifier).
  • New ignore_usage_of_identifier_in_dot_chain at the drop site balances the target usage recorded during visit; ignore_usage uses saturating_sub so pre-existing unbound-root drops (console.*) aren't at risk of underflow.
Extended reasoning...

Overview

The PR extends --drop=<name> and --drop=<root>.<prop> to match calls whose target resolves to a bound symbol (imports, local function/const, namespace imports), where previously only unbound (free global) roots matched. It touches src/js_parser/p.rs (adds allow_bound_root param to is_dot_define_match; adds EImportIdentifier arm to ignore_usage_of_identifier_in_dot_chain) and src/js_parser/visit/visit_expr.rs (new bound branch in e_identifier; per-entry allow_bound_root in the e_dot define loop; EImportIdentifier accepted as a droppable target in e_call; ignore_usage_of_identifier_in_dot_chain on the target when a call is dropped). Eight new itBundled cases exercise imported/default/local/namespace/dot roots plus two negative guards.

Security risks

None. This is bundler AST transformation logic; no untrusted-input parsing surface, syscalls, or network paths are involved.

Level of scrutiny

High. This changes what code the bundler strips from user output. Even though it aligns with the documented "anyIdentifier" behavior, it silently changes bundle output for anyone who has a bound local/import shadowing a name they pass to --drop. The is_dot_define_match signature change and the new ignore_usage_of_identifier_in_dot_chain call at visit_expr.rs:2030 also apply to the pre-existing unbound drop paths, so all existing --drop behavior flows through modified code. A maintainer should confirm this scope is intended.

Other factors

The implementation is careful: the new e_identifier branch fires only when property_access_for_method_call_maybe_should_replace_with_undefined is set (i.e., the identifier is a call target) and only consults the drop flag, so --define substitution semantics for bound identifiers are unchanged. In e_dot, allow_bound_root is derived from the define entry's drop flag before matching, but the actual replacement still requires the call-target flag at line 1391-1395, so non-call reads of a bound logger.debug won't be replaced by the drop flag (they may match, but valueless() on drop entries prevents value substitution). Negative tests cover the bare-identifier non-call case; there is no explicit negative test for the dotted non-call case (const ref = logger.debug with --drop=logger.debug), which a maintainer might want. The PR reports the broader bundler suites (edgecase, esbuild/dce, minify, regressions, transpiler) as green.

@robobun

robobun commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator Author

Added drop/BoundDotNonCallNotReplaced covering the dotted non-call case the review flagged (const ref = logger.debug with --drop=logger.debug keeps the reference). 23/23 in bundler_drop.test.ts.

On scope: the new bound-identifier branch only consults the method_call_must_be_replaced_with_undefined flag and only when the identifier is being visited as a call target, so --define substitution and non-call reads are unchanged. The ignore_usage_of_identifier_in_dot_chain call applies uniformly so the dropped target's use count is released for tree-shaking; for the pre-existing unbound path that is a benign saturating_sub on a symbol with no declaration.

@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 bugs found. Deferring to a human because this is a deliberate semantic change to --drop (now matches imports/locals, not just unbound globals) — the implementation and negative guards look right, but a maintainer should confirm the product intent.

What was reviewed:

  • New bound-identifier branch in e_identifier is gated on the call-target flag, so --define substitution and non-call reads stay unbound-only (covered by DefineDoesNotReplaceBoundIdentifier / BoundIdentifierNonCallNotReplaced).
  • is_dot_define_match(.., allow_bound_root): both call sites updated; e_dot only enables it for drop entries, and the flag it sets is still guarded by property_access_for_method_call_maybe_should_replace_with_undefined, so const ref = logger.debug is unaffected.
  • ignore_usage_of_identifier_in_dot_chain at the replacement site runs after is_control_flow_dead is restored, so the decrement balances the target-visit's record_usage; for the pre-existing unbound path it's a saturating no-op.
Extended reasoning...

Overview

Extends --drop=<name> / --drop=<root>.<prop> to match calls through bound identifiers (imports, local function/const, namespace imports), where previously only unbound globals matched. Touches src/js_parser/p.rs (is_dot_define_match gains allow_bound_root; ignore_usage_of_identifier_in_dot_chain handles EImportIdentifier), src/js_parser/visit/visit_expr.rs (new bound branch in e_identifier, allow_bound_root wiring in e_dot, EImportIdentifier accepted as droppable target in e_call, and ignore_usage on the target chain when a call is dropped), and adds 9 bundler tests.

Security risks

None. No untrusted-input parsing changes; this only affects which call expressions the bundler elides when the user explicitly opts in via --drop.

Level of scrutiny

Moderate-to-high. The diff is small (~45 lines of Rust) and well-scoped, but it changes bundler output semantics for anyone using --drop against a name that happens to be bound in scope. The PR frames this as aligning with the documented drop: ["anyIdentifier.or.propertyAccess"] contract, which is a reasonable read, but it is still a user-visible behavior change that a maintainer should sign off on rather than a pure bug fix.

Other factors

The negative guards are the important part and they check out: the new bound-identifier branch in e_identifier (visit_expr.rs:324) is behind property_access_for_method_call_maybe_should_replace_with_undefined, which is only set from e_call's target visit and propagated through e_dot, so bare reads and --define value substitution are untouched. In e_dot, allow_bound_root is derived per-entry from method_call_must_be_replaced_with_undefined(), so a plain --define=a.b=... entry still uses the old unbound-only root check; the only observable side effect for a bound-root non-call match is copying can_be_removed_if_unused onto the dot, which is consistent with the unbound case and arguably desirable under an explicit --drop. The added ignore_usage_of_identifier_in_dot_chain at the replacement site is placed after is_control_flow_dead is restored to old_is_control_flow_dead, so it correctly balances the usage recorded when the target was visited (and is a saturating no-op for unbound symbols with no declaration). All call sites of is_dot_define_match were updated. Test coverage is thorough (imported named/default/namespace, local function/const, dotted bound root, plus three negative guards) and the author reports edgecase/dce/minify/regressions/transpiler suites green.

@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 bugs found, but this broadens --drop to match bound identifiers (imports, local const/function) — a user-facing semantic change routed through core visitor paths (e_identifier, e_dot, e_call, use-count accounting). Worth a human confirming the intended semantics.

Checked: the new bound-identifier branch is gated on call-target context only (property_access_for_method_call_maybe_should_replace_with_undefined), so --define and non-call reads are unchanged; drop entries are always valueless per DefineData::parse/merge, so allow_bound_root in e_dot never triggers value substitution; the new ignore_usage_of_identifier_in_dot_chain call runs after is_control_flow_dead is restored, so it balances the target's prior record_usage and no-ops on EUndefined (import.meta.hot path).

Extended reasoning...

Overview

Extends --drop=<name> to match calls through bound identifiers (imports, local declarations, namespace imports) instead of only unbound globals. Touches src/js_parser/p.rs (is_dot_define_match gains an allow_bound_root param; ignore_usage_of_identifier_in_dot_chain handles EImportIdentifier) and src/js_parser/visit/visit_expr.rs (e_identifier adds a bound-symbol drop-flag branch; e_dot sets allow_bound_root per-entry from the drop flag; e_call accepts EImportIdentifier targets and now calls ignore_usage_of_identifier_in_dot_chain on the dropped target). Nine new itBundled tests including three negative guards.

Security risks

None. Bundler transform behavior; no untrusted-input parsing, auth, crypto, or filesystem changes.

Level of scrutiny

Medium-high. The diff is small and internally consistent, but it lives in the parser's expression visitor and adjusts symbol use-count accounting — an area where off-by-one balancing errors show up as tree-shaking regressions far from the change. It also intentionally changes user-visible semantics: --drop=log will now remove calls to a local function log(), which is what the docs describe but is a broadening a maintainer should sign off on.

Other factors

  • I traced the allow_bound_root path in e_dot: drop entries are constructed with value_is_undefined=true (defines.rs:576) and merge forces valueless when either side has the drop flag, so a bound-root match never falls into !valueless() value substitution. can_be_removed_if_unused will now be set on bound-root EDot matches, which is benign.
  • ignore_usage_of_identifier_in_dot_chain(e_.target) is called after p.is_control_flow_dead is restored to its pre-visit value, so ignore_usage's dead-flow guard behaves correctly relative to the target visit (which happened before the flag was flipped). For pre-existing unbound targets this is a newly-added saturating_sub(1) that balances the earlier record_usage; for EUndefined (import.meta.hot) it hits the _ => return arm.
  • Negative guards cover non-call bound identifier, non-call bound dot, and --define on a bound identifier — the obvious over-reach hazards.
  • Gate evidence shows fail-before/pass-after on ASAN and release; bundler_edgecase, esbuild/dce, bundler_minify, and transpiler.test.js are reported green.

Deferring because this is a deliberate semantic change to a user-facing bundler option flowing through hot visitor code; a maintainer should confirm the behavior is desired.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants