Skip to content

resolver: allow @-prefixed subpaths under wildcard exports#30188

Merged
Jarred-Sumner merged 4 commits into
mainfrom
farm/b6cd1ad0/wildcard-exports-at-prefix
May 4, 2026
Merged

resolver: allow @-prefixed subpaths under wildcard exports#30188
Jarred-Sumner merged 4 commits into
mainfrom
farm/b6cd1ad0/wildcard-exports-at-prefix

Conversation

@robobun

@robobun robobun commented May 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes #30187.

Repro

A wildcard exports pattern "./*": "./dist/packages/*" failed to
resolve subpaths whose matched substring starts with @:

$ bun --print 'await Bun.resolve("test-pkg/@scope/sub/index.js", process.cwd())'
error: Cannot find module 'test-pkg/@scope/sub/index.js' from '/tmp/test'

Node resolves it fine. The file exists on disk — it's purely a specifier
parsing bug.

Motivating real-world case: ember-source@6.12's package.json is
{ "exports": { "./*": "./dist/packages/*" } } and its internal
subpackages live at dist/packages/@ember/*, dist/packages/@glimmer/*,
dist/packages/@simple-dom/*. Every one of those subpaths failed in Bun.

Cause

ESModule.Package.parse in src/resolver/package_json.zig scanned the
entire specifier for @ to split off pkg@version:

const offset: usize = if (package.name.len == 0 or package.name[0] != '@') 0 else 1;
if (strings.indexOfChar(specifier[offset..], '@')) |at| { ... }

For test-pkg/@scope/sub/index.js this found the @ at index 9 (start
of @scope) and misparsed it as a version delimiter — the name became
test-pkg/, the "version" became scope, and the subpath parse read
from the wrong offset. The resolver then went looking for a package
called test-pkg/ in node_modules, didn't find it, and errored.

Same shape for any @ past the first /: pkg/with@sign/sub failed too.

Fix

parseName already bounds the name to the first / (or the second /
for scoped names). A version delimiter @ can only appear within
that span — e.g. in test-pkg@1.0.0/sub, the @ is at index 8 and
package.name.len is 14. Restrict the search to
specifier[offset..package.name.len] and any @ outside it is subpath
content, so the no-version branch fires and parseSubpath gets the
correct slice.

Verification

All three failing forms from the issue now resolve:

$ bun --print 'await Bun.resolve("test-pkg/plain/index.js", process.cwd())'
.../test-pkg/dist/packages/plain/index.js
$ bun --print 'await Bun.resolve("test-pkg/@scope/sub/index.js", process.cwd())'
.../test-pkg/dist/packages/@scope/sub/index.js
$ bun --print 'await Bun.resolve("test-pkg/with@sign/sub/index.js", process.cwd())'
.../test-pkg/dist/packages/with@sign/sub/index.js

ember-source-shaped specifiers (@ember/*, @glimmer/*,
@simple-dom/* via a wildcard) all resolve.

New tests in test/js/bun/resolve/resolve.test.ts:

  • @-prefixed subpath under a plain package (test-pkg/@scope/sub)
  • Mid-segment @ (test-pkg/with@sign/sub)
  • @-prefixed subpath under a scoped package (@my/pkg/@inner/bar)
  • Regression guard: pkg@1.0.0/subpath still strips the version

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

robobun commented May 3, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:12 AM PT - May 3rd, 2026

@robobun, your commit b76b1bf has 5 failures in Build #50624 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30188

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

bun-30188 --bun

@coderabbitai

coderabbitai Bot commented May 3, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d35c6628-37ff-4501-9541-55b81ba21851

📥 Commits

Reviewing files that changed from the base of the PR and between f017372 and b70912e.

📒 Files selected for processing (1)
  • test/js/bun/resolve/resolve.test.ts

Walkthrough

Restrict detection of the @ version delimiter to the package-name substring so @ characters in wildcard-matched subpaths are not misinterpreted as version separators.

Changes

Package Specifier Parsing

Layer / File(s) Summary
Core Logic
src/resolver/package_json.zig
ESModule.Package.parse computes an offset for scoped names and searches for the @ delimiter only within the package-name substring. If found, it sets package.version, updates package.name, and calls parseSubpath after the name+version segment; otherwise it calls parseSubpath after package.name.
Tests
test/js/bun/resolve/resolve.test.ts
Added describe("wildcard exports with @ in matched subpath", ...) with concurrent Bun.resolveSync cases covering @ at the start of wildcard matches, @ mid-segment, @ inside scoped-package subpaths, and regression tests ensuring pkg@1.0.0/... and @scope/pkg@1.0.0/... still strip only package-level version.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: allowing @-prefixed subpaths to be resolved under wildcard exports patterns, which is the core fix in this PR.
Description check ✅ Passed The PR description comprehensively covers all template sections: it explains what the PR does (fixes wildcard exports with @ in subpaths), provides reproduction steps, explains the root cause, describes the fix, and includes verification with examples and test coverage.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Review rate limit: 3/5 reviews remaining, refill in 18 minutes and 43 seconds.

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

@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 search-range narrowing is correct and the regression guard confirms version stripping still works.

Extended reasoning...

Overview

This PR changes a single function, ESModule.Package.parse in src/resolver/package_json.zig, restricting the @-version-delimiter search from the entire specifier to just the package-name span returned by parseName. The rest of the diff is reindentation plus an early return to flatten the control flow. Four new tests in test/js/bun/resolve/resolve.test.ts cover the three previously-failing shapes (pkg/@scope/..., pkg/with@sign/..., @scope/pkg/@inner/...) and a regression guard for pkg@1.0.0/sub.

Security risks

None. This is pure bounded string slicing on an in-memory specifier. The new offset < package.name.len guard ensures the slice bounds are valid (defensive against the degenerate empty-name case). No filesystem, network, or privilege boundaries are involved.

Level of scrutiny

Module resolution is a hot, correctness-critical path, so I traced the logic for each specifier shape. The key invariant holds: a real version delimiter (pkg@ver/sub or @scope/pkg@ver/sub) always sits before the first (or second, for scoped) /, which is exactly where parseName bounds package.name — so the @ is still inside specifier[offset..package.name.len] and version stripping is preserved. Any @ past that boundary is subpath content and now correctly falls through to the no-version branch. The change strictly narrows behavior for inputs that previously failed to resolve, so the blast radius of any oversight is limited to already-broken cases.

Other factors

No CODEOWNERS cover these files. The bug hunter found nothing. The PR description includes a thorough cause analysis that matches the code, and the regression test (test-pkg@1.0.0/plain/index.js) directly exercises the path that could have regressed. The real-world motivator (ember-source's wildcard exports with @ember/* subpaths) is a concrete compatibility win.

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.
Comment thread test/js/bun/resolve/resolve.test.ts Outdated
The numbers matched a '@scope/pkg' stand-in draft, not the '@my/pkg'
fixture the test uses. Keep the comment fixture-agnostic.

@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 stale comment offsets were fixed in b70912e, and bounding the @ search to the parseName span is provably safe since that span already includes any @version suffix.

Extended reasoning...

Overview

This PR changes one function, ESModule.Package.parse in src/resolver/package_json.zig, to bound the @-version-delimiter search to specifier[offset..package.name.len] instead of specifier[offset..]. The rest of the Zig diff is reindentation plus an early return package and a fall-through to the existing no-version branch. Five new test cases in test/js/bun/resolve/resolve.test.ts cover @-prefixed subpaths, mid-segment @, scoped packages, and two regression guards for the existing pkg@version / @scope/pkg@version stripping.

Security risks

None. This is pure string-slicing of a module specifier. The new offset < package.name.len guard prevents the only new failure mode (an invalid [1..0] slice). No filesystem, network, auth, or user-controlled-eval surface is touched beyond what the resolver already does.

Level of scrutiny

The resolver is a hot, critical path, so I traced the logic carefully. The key correctness argument is that parseName bounds on / (not @), so when a specifier has the form pkg@1.0.0/sub or @scope/pkg@1.0.0/sub, the returned name span already contains the @version substring. Restricting the @ search to that span therefore cannot lose any true version delimiter — it only stops matching @ characters that live in the subpath (the bug). I walked through all five test fixtures (test-pkg/@scope/..., test-pkg/with@sign/..., @my/pkg/@inner/..., test-pkg@1.0.0/..., @my/pkg@1.0.0/...) and each takes the expected branch.

Other factors

My earlier inline comment about stale worked-index numbers in a test comment was addressed in b70912e (the comment is now fixture-agnostic). No CODEOWNERS entry covers src/resolver/. The bug-hunting system found no issues on the current revision, and the regression guards explicitly pin the pre-existing @version-stripping behavior so a future change can't silently break it.

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.

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

Logic looks correct and the stale-comment nit is addressed — deferring only because resolver/specifier-parsing changes are hot-path enough that a maintainer should sign off.

Extended reasoning...

Overview

This PR changes ESModule.Package.parse in src/resolver/package_json.zig to bound the @version delimiter search to specifier[offset..package.name.len] instead of the full specifier, so that @ characters appearing in wildcard-matched subpaths (e.g. ember-source/@ember/...) are no longer misparsed as version delimiters. The control flow is restructured from if/else to nested-if + early return + fall-through, which is semantically equivalent. Five new test cases in test/js/bun/resolve/resolve.test.ts cover the bug fix and regression-guard the existing pkg@ver/sub and @scope/pkg@ver/sub version-stripping behavior.

Security risks

None identified. The change strictly narrows a string search range using a bound (package.name.len) that is already guaranteed to be <= specifier.len since parseName returns a slice of specifier. The new offset < package.name.len guard prevents any out-of-range slicing for degenerate names.

Level of scrutiny

Medium-high. The diff is small (~15 lines, mostly re-indentation) and I traced it against all five test specifiers — each hits the intended branch. However, ESModule.Package.parse runs on every bare-specifier import in Bun, so even a narrow change here has very broad blast radius. That's the only reason I'm not approving outright.

Other factors

My one prior inline comment (stale worked-offset numbers in a test comment) was addressed in b70912e by making the comment fixture-agnostic, and the thread is resolved. The bug-hunting pass on the latest revision found nothing. Test coverage is solid, including regression guards for both unscoped and scoped @version stripping. CodeRabbit had no actionable comments.

@robobun

robobun commented May 3, 2026

Copy link
Copy Markdown
Collaborator Author

Build #50624 final state: 279/288 jobs passed. The 9 failures are pre-existing CI flakes unrelated to this PR:

The resolver test file test/js/bun/resolve/resolve.test.ts (where all 5 wildcard exports with @ in matched subpath cases live) ran inside one of the 279 passing shards. PR #30145 was merged yesterday with similar Windows flake failures — same practice applies here.

@Jarred-Sumner Jarred-Sumner merged commit f58cd4b into main May 4, 2026
76 of 77 checks passed
@Jarred-Sumner Jarred-Sumner deleted the farm/b6cd1ad0/wildcard-exports-at-prefix branch May 4, 2026 08:17
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
…sh#30188)

Fixes oven-sh#30187.

## Repro

A wildcard `exports` pattern `"./*": "./dist/packages/*"` failed to
resolve subpaths whose matched substring starts with `@`:

```console
$ bun --print 'await Bun.resolve("test-pkg/@scope/sub/index.js", process.cwd())'
error: Cannot find module 'test-pkg/@scope/sub/index.js' from '/tmp/test'
```

Node resolves it fine. The file exists on disk — it's purely a specifier
parsing bug.

Motivating real-world case: `ember-source@6.12`'s `package.json` is
`{ "exports": { "./*": "./dist/packages/*" } }` and its internal
subpackages live at `dist/packages/@ember/*`,
`dist/packages/@glimmer/*`,
`dist/packages/@simple-dom/*`. Every one of those subpaths failed in
Bun.

## Cause

`ESModule.Package.parse` in `src/resolver/package_json.zig` scanned the
**entire** specifier for `@` to split off `pkg@version`:

```zig
const offset: usize = if (package.name.len == 0 or package.name[0] != '@') 0 else 1;
if (strings.indexOfChar(specifier[offset..], '@')) |at| { ... }
```

For `test-pkg/@scope/sub/index.js` this found the `@` at index 9 (start
of `@scope`) and misparsed it as a version delimiter — the name became
`test-pkg/`, the "version" became `scope`, and the subpath parse read
from the wrong offset. The resolver then went looking for a package
called `test-pkg/` in `node_modules`, didn't find it, and errored.

Same shape for any `@` past the first `/`: `pkg/with@sign/sub` failed
too.

## Fix

`parseName` already bounds the name to the first `/` (or the second `/`
for scoped names). A version delimiter `@` can only appear **within**
that span — e.g. in `test-pkg@1.0.0/sub`, the `@` is at index 8 and
`package.name.len` is 14. Restrict the search to
`specifier[offset..package.name.len]` and any `@` outside it is subpath
content, so the no-version branch fires and `parseSubpath` gets the
correct slice.

## Verification

All three failing forms from the issue now resolve:

```console
$ bun --print 'await Bun.resolve("test-pkg/plain/index.js", process.cwd())'
.../test-pkg/dist/packages/plain/index.js
$ bun --print 'await Bun.resolve("test-pkg/@scope/sub/index.js", process.cwd())'
.../test-pkg/dist/packages/@scope/sub/index.js
$ bun --print 'await Bun.resolve("test-pkg/with@sign/sub/index.js", process.cwd())'
.../test-pkg/dist/packages/with@sign/sub/index.js
```

ember-source-shaped specifiers (`@ember/*`, `@glimmer/*`,
`@simple-dom/*` via a wildcard) all resolve.

New tests in `test/js/bun/resolve/resolve.test.ts`:

- `@`-prefixed subpath under a plain package (`test-pkg/@scope/sub`)
- Mid-segment `@` (`test-pkg/with@sign/sub`)
- `@`-prefixed subpath under a scoped package (`@my/pkg/@inner/bar`)
- Regression guard: `pkg@1.0.0/subpath` still strips the version

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
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.

Resolver: wildcard exports pattern fails when matched substring starts with @

2 participants