Make EString::string() join folded ropes, fixing require.resolve() and other readers of the first segment - #38944
Make EString::string() join folded ropes, fixing require.resolve() and other readers of the first segment#38944robobun wants to merge 4 commits into
Conversation
…er shapes and macro destructuring
Constant folding turns "./fo" + "o" into a rope E::String whose `data` holds
only the first segment. Three consumers read it through EString::string(),
which does not walk the rope:
- transpose_require_resolve_known_string recorded "./fo" as the import
record path, so require.resolve("./fo" + "o") was emitted (and resolved)
as require.resolve("./fo"). require() and import() already flattened.
- extract_dynamic_specifier_shape built --allow-unresolved shapes from rope
template heads and tails, reporting "./fo*" for "./fo" + `o/${x}` and
rejecting patterns that match the real specifier.
- Expr::as_string_literal, used to match destructuring keys against an
inlined macro result, dropped the property for a computed folded key.
Flatten the rope at each site, and debug_assert in EString::string() that it
is not handed an unresolved rope.
|
Updated 12:13 PM PT - Aug 15th, 2026
🔄 @robobun, the build for your commit |
|
Warning Review limit reached
Next review available in: 43 seconds Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (12)
Comment |
|
Status: fix pushed (head c9684ba), waiting on CI. Reproduced on bun 1.4.0 and on main with the snippets in the description ( |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Not a duplicate of #35680, but the two do touch the same function.
|
There was a problem hiding this comment.
Beyond the inline nit, I checked that as_string_literal's switch from &self.data to self.data is sound (Data::EString holds a Copy StoreRef, so s.slice() flattens the arena node in place via DerefMut — same pattern as the printer). Also confirmed the four extra .data readers the nit names all bypass string(), so the new debug_assert won't fire on them.
Extended reasoning...
The three fixed sites each adopt the exact flatten-first pattern their working siblings (transpose_import, transpose_require) already use, and every fix ships with a test that fails on the unfixed build. The only finding is a nit about pre-existing same-class .data readers the .string() audit didn't cover; it doesn't affect the correctness of what this PR changes. I verified the StoreRef copy in as_string_literal and that the new debug assertion can't be tripped by those extra sites since they read the field directly.
Same class as the previous commit, found by sweeping direct `.data` reads
rather than string() callers:
- Import::import_record_loader: import() options are folded unconditionally,
so `with: { type: "js" + "on" }` looked up the "js" loader. Object::get
goes through eql_bytes, which now compares the whole rope, so folded
computed keys in the options object match too.
- feature() from "bun:bundle" looked up the first segment of a folded name.
- The e_index visitor passed the first segment to maybe_rewrite_property_access
when minify_syntax was off but the index had still been folded (inside
require()/import()/macro arguments), binding ns["fo" + "o"] to `fo`.
- The React Fast Refresh signature hash covered only the first segment.
- import.meta.hot.accept() specifier matching (not reachable with a rope
today, HMR is only on in dev mode where folding is off).
string() and string_cloned() now return the whole folded string, which fixes require.resolve(), the --allow-unresolved shape and macro destructuring without touching those call sites; they go back to their original code. The comparators that still read only `data` (hash, order, eql_string) assert they are not handed a rope; every caller normalizes first. Tests now cover the realistic way a rope reaches the index visitor: string enum members built with "+" are always folded, so ns[K.X] and exports[K.X] used to pick the member named by the first segment, as did E["fo" + "o"] inside require()/require.resolve()/import().
|
Pushed c9684ba, which restructures the fix following review:
|
There was a problem hiding this comment.
I reviewed the final revision (c9684ba) and didn't find any bugs. The refactor — making EString::string() join ropes itself instead of asserting and pushing the flatten to every caller — is cleaner than the earlier approach and subsumes the .string()-caller fixes (transpose_require_resolve_known_string, extract_dynamic_specifier_shape, as_string_literal). Given this touches parser/AST hot paths and coordinates with #35680 and #38787, a maintainer sign-off is still worthwhile.
What was reviewed:
join_ropeextraction and its two callers;string_cloned's new delegation preserves the fresh-copy contract for the non-rope UTF-8 case.eql_bytesnow walks the rope viaeql8_rope, soObject::getmatches folded computed keys (covered by the["wi" + "th"]loader test).- The new
debug_asserts onhash/order/eql_string— checked their callers (JSON sorting,alphabetize_*, mangled-prop dedup) all run on parser-produced or JSON-tape strings, never post-fold ropes. write_to_hasher's segment loop feeds bytes back-to-back with the trailing\x00, so"a"+"b"and"ab"hash identically as the test asserts.
Extended reasoning...
Overview
This PR fixes a class of bugs where post-visit readers of E::String saw only the first segment of a folded rope ("./fo" + "o" → "./fo"). It touches four Rust source files (src/ast/e.rs, src/ast/expr.rs, src/js_parser/p.rs, src/js_parser/visit/visit_expr.rs) and adds tests across eight files. The PR went through three substantive revisions: the initial three-site fix, a sweep of direct .data readers after my first review, and finally c9684ba which replaced the per-caller debug_assert approach with a rope-aware string() accessor plus a shared join_rope helper.
What changed since my last review
c9684ba is a design change: EString::string() now joins ropes into the arena on every call rather than asserting the caller flattened first. This is the right layering per the repo's own review rules ("fix bugs at the layer that owns the violated invariant") — every future .string() caller is now correct by construction. resolve_rope_if_needed and string() share join_rope; slice() remains the caching variant. string_cloned correctly keeps its own copy for the non-rope UTF-8 case (where string() returns a borrow, not a fresh allocation). The direct-.data fixes at import_record_loader, maybe_replace_bundler_feature_call, the e_index visitor, and rewrite_import_meta_hot_accept_string are still needed and remain in place. The new debug_asserts on hash()/order()/eql_string() cover the remaining rope-unaware readers; I traced their callers and none receive folded strings.
Security risks
None. The changes are to compile-time constant folding of string literals in the transpiler; no untrusted-input parsing, no auth/crypto/permissions.
Level of scrutiny
High — this is parser/AST code on the hot path of every transpile and bundle. The fix is well-tested (each of the eight fixed sites has a targeted test that fails on the unfixed build), but the surface area, the cross-PR coordination (#35680 rewrites extract_dynamic_specifier_shape; #38787 replaces the deferred decorator-key reader), and the two open comment-cop nags on the new string() doc comment mean a maintainer should sign off rather than an automated approval.
Other factors
Both of my earlier inline threads have justified responses (the decorator-key reader is owned by #38787 with tests there; the prev_should_fold = true pre-existing bug is filed separately as it changes when ropes are made, not how they're read). Test coverage is thorough and follows harness conventions. The two comment-cop comments at 14:45:40 look like false positives (one is a doc comment, one is pre-existing repositioned code) but are unaddressed.
Problem
require.resolve("./fo" + "o")is transpiled torequire.resolve("./fo")and throwsCannot find module './fo'at runtime, whilerequire("./fo" + "o")next to it works. Same throughBun.Transpiler(transformSyncandscan()report./fo),bun build, and each branch ofrequire.resolve(x ? "./fo" + "o" : "./ba" + "r"). Node resolves./foo."./fo" + "o"produces a rope (see Background) whosedataholds only the first segment, andEString::string()(src/ast/e.rs) returneddatawithout walking the rope.transpose_require_resolve_known_stringrecords the import record path throughstring();transpose_importandtranspose_requirehappen to flatten first, which is whyrequire()andimport()work.string(): the--allow-unresolvedshape ("./lo" + \cales/${x}.json`was reported as./lo*.json, so a pattern matching the real specifier was rejected) and macro result destructuring (const { ["a" + "b"]: x } = m()dropped theab` property).datadirectly:import()attributes (with: { type: "js" + "on" }selected thejsloader, andObject::getdid not match folded computed keys),feature()frombun:bundleunderminify.syntax, the index visitor withoutminifySyntax(ns[K.X]andexports[K.X]withenum K { X = "fo" + "o" }bound and exportedfoinstead offoo;E["fo" + "o"]insiderequire()pickedE.fo), the React Fast Refresh signature hash (useState("a" + "b")anduseState("a" + "c")got the same signature), andimport.meta.hot.accept()matching (not reachable with a rope today, HMR is only on in dev mode where folding is off).Fix
EString::string()(andstring_cloned()) join the rope into the arena whennextis set, through the same helperresolve_rope_if_needednow uses, andeql_bytescompares the whole rope likeeql_comptimealready did. That alone fixesrequire.resolve, the shape and the macro case; those call sites are unchanged from main.slice()keeps storing the joined bytes back into the node for callers that read it repeatedly.dataitself callresolve_rope_if_neededfirst (import_record_loader, which now takes the arena from its one caller;feature(); the index visitor;hot.accept), and the refresh hasher feeds the segments back to back so a folded string hashes like the equivalent literal.hash(),order()andeql_string()still readdataonly; they nowdebug_assertthey are not handed a rope. Every caller normalizes first (Data::eqlandextract_string_valuesresolve both sides, the react compiler'sJsStringasserts the same at construction, package.json sorting never sees ropes), so this only guards future callers.require.resolvenow records the same pathrequirerecords for the same argument, which is what Node resolves.["a" + "b"]test, in js_parser: name lowered anonymous classes after numeric, non-ASCII and private property keys #38787), the_namehelper variables for decorated auto-accessors (cosmetic), and the"str"[i]/charCodeAt(i)folds (they bail past the first segment, so they only miss an optimization). Four different rope bugs found while sweeping are tracked separately (React Compiler template text loss,Template::foldmutating an inlined enum's rope,.lengthfolding counting bytes, and the fold flag staying on after animport()).extract_dynamic_specifier_shape; that function is untouched here, so there is no conflict, and its per-segmentstring()calls keep working sincestring()now handles ropes.bun bd test; every new test fails on the unfixed build:test/bundler/transpiler/transpiler.test.js: printed output for two- and three-segment concatenations and the ternary form,scan()paths,require()/import()controls, andE["fo" + "o"]insiderequire()/require.resolve()/import().test/js/bun/resolve/resolve.test.ts:require.resolveof folded specifiers returns the right files at runtime.test/bundler/bundler_allow_unresolved.test.ts: rope head from+, rope head from template folding, rope tail, and therequire.resolve()path accept a pattern matching the full specifier.test/bundler/transpiler/macro-test.test.ts: a computed folded key keeps its property when destructuring a macro result.test/bundler/bundler_loader.test.ts: foldedtypevalue, and folded computedwith/typekeys, select the JSON loader.test/bundler/bundler_feature_flag.test.ts: a folded flag name is looked up whole (CLI and API backends).test/bundler/bundler_edgecase.test.ts:ns[K.X]reads thefooexport andexports[K.X]exportsfoo, for a string enum member built with+, in a plain (unminified) bundle.test/bundler/bun-build-api.test.ts:"a" + "b"gets the same refresh signature as"ab"and a different one from"a" + "c".test/bundler/transpiler/*,test/bundler/esbuild/{default,ts,dce}.test.ts,bundler_edgecase,bundler_minify,bundler_string,bundler_loader,bun-build-api, the TOML tests andtest/cli/install/npmrc.test.tson the debug build.Background
"a" + "b"it does not copy bytes. It links the right operand onto the leftE::Stringthrough itsnextpointer and bumpsrope_len;datastill holds only"a". Template literals get the same treatment for their head and for the text after each${}. Ropes are produced in two places (fold_string_additionandTemplate::fold) and read in many, which is why the fix goes into the accessor.minify_syntaxor inlining is on (bun runandtarget: "bun"builds enable both); always, whatever the flags, for enum initializers and for the arguments ofrequire(),require.resolve(), macro calls andimport()including its options object. The enum rule is what makes the index visitor cases reachable from an ordinary unminifiedbun build: a string enum member built with+is a rope everywhere it is inlined.E::RequireResolveStringfrom the record's path, so the recorded text is both what gets resolved and what ends up in the output.--allow-unresolvedshape: for a dynamic specifier written as a template literal, the parser joins the literal parts with\0standing in for each interpolation and matches that against the user's glob patterns.const { a } = someMacro()inlines the macro's return value, the parser keeps only the properties the pattern names, matching each binding key against the inlined object.Repros on the unfixed build
Earlier revisions of this description
The first push flattened at the three
string()call sites and added adebug_asserttostring(); the second push added the directdatareaders after review; the third push moved the fix intostring()itself (review pointed out that the invariant lives inEStringand that per-call-site flattening is how this bug keeps recurring), reverted the three call sites to their original code, and replaced the contrived index-visitor test with the enum cases above.no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/bundler/bun-build-api.test.ts test/bundler/bundler_edgecase.test.ts test/js/bun/resolve/resolve.test.ts