From 09e02c93f6b29951c4b77f0a89b93dd94e02db28 Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 3 May 2026 13:18:16 +0000 Subject: [PATCH 1/4] resolver: bound @version split to package-name span for wildcard exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ESModule.Package.parse scanned the entire specifier for '@' to split off a package version. For wildcard 'exports' maps the matched substring can contain '@' (e.g. 'ember-source/@ember/renderer/...', 'pkg/@scope/sub') — those '@' chars aren't version delimiters, they're subpath content. The resolver misparsed them as 'pkg@version' and failed to find the module. Restrict the '@' search to specifier[offset..package.name.len]. parseName already bounded the name to the first '/' (or 2nd '/' for scoped names), so a version delimiter can only appear before that cutoff. Fixes #30187. --- src/resolver/package_json.zig | 28 ++++++---- test/js/bun/resolve/resolve.test.ts | 83 +++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 11 deletions(-) diff --git a/src/resolver/package_json.zig b/src/resolver/package_json.zig index e6b592078c2..20864ae4993 100644 --- a/src/resolver/package_json.zig +++ b/src/resolver/package_json.zig @@ -1456,21 +1456,27 @@ pub const ESModule = struct { if (strings.startsWith(package.name, ".") or strings.indexAnyComptime(package.name, "\\%") != null) return null; + // A version delimiter `@` is only valid within the package-name portion of + // the specifier. Searching the entire specifier misparses wildcard subpaths + // whose matched substring contains `@` (e.g. `test-pkg/@scope/sub/index.js` + // or `ember-source/@ember/renderer/...`) as if the package had a version. const offset: usize = if (package.name.len == 0 or package.name[0] != '@') 0 else 1; - if (strings.indexOfChar(specifier[offset..], '@')) |at| { - package.version = parseVersion(specifier[offset..][at..]) orelse ""; - if (package.version.len == 0) { - package.version = specifier[offset..][at..]; - if (package.version.len > 0 and package.version[0] == '@') { - package.version = package.version[1..]; + if (offset < package.name.len) { + if (strings.indexOfChar(specifier[offset..package.name.len], '@')) |at| { + package.version = parseVersion(specifier[offset..][at..]) orelse ""; + if (package.version.len == 0) { + package.version = specifier[offset..][at..]; + if (package.version.len > 0 and package.version[0] == '@') { + package.version = package.version[1..]; + } } - } - package.name = specifier[0 .. at + offset]; + package.name = specifier[0 .. at + offset]; - parseSubpath(&package.subpath, specifier[@min(package.name.len + package.version.len + 1, specifier.len)..], subpath_buf); - } else { - parseSubpath(&package.subpath, specifier[package.name.len..], subpath_buf); + parseSubpath(&package.subpath, specifier[@min(package.name.len + package.version.len + 1, specifier.len)..], subpath_buf); + return package; + } } + parseSubpath(&package.subpath, specifier[package.name.len..], subpath_buf); return package; } diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index 8a09744063b..5103da814b1 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -485,6 +485,89 @@ it.skipIf(isWindows)("browser map resolution handles relative paths longer than expect(exitCode).toBe(0); }); +// ESModule.Package.parse scanned the entire specifier for an `@` to split off a +// version. For wildcard `exports` maps the matched substring can contain `@` +// (e.g. `ember-source/@ember/renderer/...`, `pkg/@scope/sub`) — those `@`s +// aren't version delimiters, they're subpath content. The version split must +// be bounded to the package-name portion of the specifier. +// https://github.com/oven-sh/bun/issues/30187 +describe("wildcard exports with @ in matched subpath", () => { + it.concurrent("resolves a subpath whose wildcard match starts with @", () => { + using dir = tempDir("resolver-wildcard-at-scoped", { + "package.json": JSON.stringify({ name: "host" }), + "node_modules/test-pkg/package.json": JSON.stringify({ + name: "test-pkg", + version: "1.0.0", + exports: { "./*": "./dist/packages/*" }, + }), + "node_modules/test-pkg/dist/packages/plain/index.js": "export default 'plain';", + "node_modules/test-pkg/dist/packages/@scope/sub/index.js": "export default 'scoped';", + }); + const root = String(dir); + + expect(Bun.resolveSync("test-pkg/plain/index.js", root)).toBe( + join(root, "node_modules/test-pkg/dist/packages/plain/index.js"), + ); + expect(Bun.resolveSync("test-pkg/@scope/sub/index.js", root)).toBe( + join(root, "node_modules/test-pkg/dist/packages/@scope/sub/index.js"), + ); + }); + + it.concurrent("resolves a subpath that contains `@` mid-segment", () => { + using dir = tempDir("resolver-wildcard-at-mid", { + "package.json": JSON.stringify({ name: "host" }), + "node_modules/test-pkg/package.json": JSON.stringify({ + name: "test-pkg", + version: "1.0.0", + exports: { "./*": "./dist/packages/*" }, + }), + "node_modules/test-pkg/dist/packages/with@sign/sub/index.js": "export default 'sign';", + }); + const root = String(dir); + + expect(Bun.resolveSync("test-pkg/with@sign/sub/index.js", root)).toBe( + join(root, "node_modules/test-pkg/dist/packages/with@sign/sub/index.js"), + ); + }); + + it.concurrent("resolves an @-prefixed subpath under a scoped package", () => { + using dir = tempDir("resolver-wildcard-at-scoped-pkg", { + "package.json": JSON.stringify({ name: "host" }), + "node_modules/@my/pkg/package.json": JSON.stringify({ + name: "@my/pkg", + version: "1.0.0", + exports: { "./*": "./dist/*" }, + }), + "node_modules/@my/pkg/dist/@inner/bar/index.js": "export default 'inner';", + }); + const root = String(dir); + + expect(Bun.resolveSync("@my/pkg/@inner/bar/index.js", root)).toBe( + join(root, "node_modules/@my/pkg/dist/@inner/bar/index.js"), + ); + }); + + // Regression guard: `@version` specifiers immediately following the package + // name must still be stripped. We don't install alternative versions; we just + // verify `pkg@1.0.0/subpath` still resolves to the same file as `pkg/subpath`. + it.concurrent("still strips a trailing @version after the package name", () => { + using dir = tempDir("resolver-wildcard-versioned", { + "package.json": JSON.stringify({ name: "host" }), + "node_modules/test-pkg/package.json": JSON.stringify({ + name: "test-pkg", + version: "1.0.0", + exports: { "./*": "./dist/packages/*" }, + }), + "node_modules/test-pkg/dist/packages/plain/index.js": "export default 'plain';", + }); + const root = String(dir); + + expect(Bun.resolveSync("test-pkg@1.0.0/plain/index.js", root)).toBe( + join(root, "node_modules/test-pkg/dist/packages/plain/index.js"), + ); + }); +}); + // dirInfoCachedMaybeLog reads the rfs.entries cache without checking the union // tag. If readDirectory() previously failed with a non-ENOENT error (e.g. // EACCES), a `.err` variant is stored there; re-resolving the directory after From f0173722d3f25b74253db770e1d50558a1980d6b Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 3 May 2026 13:40:33 +0000 Subject: [PATCH 2/4] test(resolve): add regression guard for @scope/pkg@version split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the scoped-package branch of the @-delimiter search — the '@' at index 10 in '@my/pkg@1.0.0/sub' sits inside the 16-char name span, so the version extraction still fires for genuine version specifiers. --- test/js/bun/resolve/resolve.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index 5103da814b1..4e4b4fa65a0 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -566,6 +566,26 @@ describe("wildcard exports with @ in matched subpath", () => { join(root, "node_modules/test-pkg/dist/packages/plain/index.js"), ); }); + + // Regression guard for the scoped-package version split: `@scope/pkg@ver/sub` + // must still strip the version (the `@` delimiter sits at index 10, inside + // the 16-char name span, so the version branch still fires). + it.concurrent("still strips @version after a scoped package name", () => { + using dir = tempDir("resolver-wildcard-scoped-versioned", { + "package.json": JSON.stringify({ name: "host" }), + "node_modules/@my/pkg/package.json": JSON.stringify({ + name: "@my/pkg", + version: "1.0.0", + exports: { "./*": "./dist/*" }, + }), + "node_modules/@my/pkg/dist/sub/index.js": "export default 'sub';", + }); + const root = String(dir); + + expect(Bun.resolveSync("@my/pkg@1.0.0/sub/index.js", root)).toBe( + join(root, "node_modules/@my/pkg/dist/sub/index.js"), + ); + }); }); // dirInfoCachedMaybeLog reads the rfs.entries cache without checking the union From b70912e22786bc9ec6ff7c86d5a90454c968db63 Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 3 May 2026 13:53:31 +0000 Subject: [PATCH 3/4] test(resolve): drop stale offset numbers from regression-guard comment The numbers matched a '@scope/pkg' stand-in draft, not the '@my/pkg' fixture the test uses. Keep the comment fixture-agnostic. --- test/js/bun/resolve/resolve.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index 4e4b4fa65a0..0befd6ee209 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -567,9 +567,10 @@ describe("wildcard exports with @ in matched subpath", () => { ); }); - // Regression guard for the scoped-package version split: `@scope/pkg@ver/sub` - // must still strip the version (the `@` delimiter sits at index 10, inside - // the 16-char name span, so the version branch still fires). + // Regression guard for the scoped-package version split: the `@version` + // delimiter still falls inside the name span `parseName` returns (between + // the leading `@` and the second `/`), so the version branch must still + // fire for `@scope/pkg@ver/sub`. it.concurrent("still strips @version after a scoped package name", () => { using dir = tempDir("resolver-wildcard-scoped-versioned", { "package.json": JSON.stringify({ name: "host" }), From b76b1bffd67e32d027ebfbdd3c358ba195cade1a Mon Sep 17 00:00:00 2001 From: robobun Date: Sun, 3 May 2026 14:24:52 +0000 Subject: [PATCH 4/4] ci: retry (flaky http test on windows) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit windows-2019-x64-test-bun shard hit test-http-should-emit-close-when-connection-is-aborted.ts timeout 4x in build #50620. Unrelated to resolver changes — other Windows shards and the sibling windows-2019-x64-test-bun shard all passed on the same binary.