Skip to content

resolver: make auto-install use the version range from the project's package.json - #38198

Closed
robobun wants to merge 1 commit into
mainfrom
farm/15564ca5/autoinstall-package-json-range
Closed

robobun wants to merge 1 commit into
mainfrom
farm/15564ca5/autoinstall-package-json-range

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Runtime auto-install (bun index.js with no node_modules) installs the latest dist-tag of a bare import even when the project's package.json declares a range for it. With "no-deps": "^1.0.0" and a registry whose latest is 2.0.0, require("no-deps") loads 2.0.0; a range nothing satisfies silently installs latest too. docs/runtime/auto-install.mdx ("Version resolution", step 2) documents the range being used.
  • Two separate causes:
    • src/resolver/package_json.rs:915 (before this change): dependency versions were only parsed through the AutoInstaller vtable. The project's package.json is parsed while the entry point is resolved, before the first bare import creates the package manager, so every entry was stored with an Uninitialized version. load_node_modules (src/resolver/resolver.rs:3041) treats that as "no range declared" and re-parses the specifier as latest. This is a regression from the Rust port (bun 1.3.14 parsed the versions here without a package manager); it is what the second cause left visible.
    • The cwd's DirInfo is created before the runtime's --install setting is applied: by the VM transpiler's configure_linker() inside VirtualMachine::init (src/runtime/jsc_hooks.rs:512, before boot calls wire_transpiler_from_ctx), and for bun run <file> earlier still by the configure_env_for_run transpiler. Both have auto-install disabled (BundleOptions::from_api default), and dir_info_uncached (src/resolver/resolver.rs:6431) only parses dependencies when the resolver creating the entry has it enabled, so the project's package.json was cached with no dependencies at all and reused from the process-wide cache by the runtime resolver. This part predates the port: bun 1.3.14 only honored the range when the package.json was in a directory below the cwd.
  • Two smaller bugs in the same path that become observable once ranges are actually used:
    • package_json.rs built the SlicedString over the version string itself, so any specifier longer than the 8-byte inline form (>=1.0.0 <1.1.0, npm:foo@^1.0.0, prerelease tags) was stored as an offset relative to the wrong base; every reader slices the map with dependencies.source_buf.
    • Lockfile::resolve_package_from_name_and_version compared the query against the lockfile's string buffer even when the query was parsed from a package.json, so prerelease tags in a range would be read from the wrong buffer.

Fix

  • PackageJSON::parse parses versions through Resolver::parse_dependency, which uses the package manager when it exists and otherwise a new link-time hook __bun_resolver_parse_dependency (bun_install::auto_installer, same mechanism as __bun_resolver_init_package_manager) that calls dependency::parse with no manager. Correct because the manager's only role in parse is recording npm: aliases, and lockfile_append_from_package_json records those anyway when the map is cloned into the lockfile; this restores what the pre-port code did.
  • VirtualMachine::InitOptions gains global_cache (default disable, the previous value), applied in init_runtime_state before configure_linker(), the same way store_fd already is; boot and the REPL pass ctx.debug.global_cache. configure_env_for_run_impl applies the same setting before it reads the directory. Correct because the setting only changes whether a package.json's dependencies are recorded when its directory is cached; these resolvers never auto-install themselves (Resolver::resolve passes GlobalCache::disable per call), and wire_transpiler_from_ctx sets the same value afterwards. Workers, bun test, compiled executables and Bun.build keep the default.
  • The SlicedString for a version is built over dependencies.source_buf, with the same in-buffer guard the key already has (a value with JSON escapes is decoded outside the buffer and is skipped, as escaped keys are).
  • AutoInstaller::lockfile_resolve and resolve_package_from_name_and_version take the buffer the version was parsed against; the resolver already tracks it (string_buf).
  • Behaviour note: a declared non-registry specifier (workspace:, link:, file:, git) now fails with Cannot find package instead of auto-installing whatever the registry has under that name. This only arises with no node_modules anywhere above the file; the old behaviour was the same as for an unsatisfiable range, i.e. a different package than the one declared.
  • Verified with test/cli/run/run-autoinstall.test.ts (new describe block, local registry serving the no-deps fixture): bun <file> and bun run <file> from the project directory, a project directory below the cwd, a nameless package.json, a range longer than the inline form, an unsatisfiable range, an npm: alias, and a package the package.json does not list (still latest). All but the last fail on the unfixed build (every one installs 2.0.0); 20/20 pass with the debug build.
  • Also run with the debug build: test/cli/run/filter-workspace.test.ts (the other consumer of this dependency map), test/js/bun/resolve/, test/js/bun/repl/repl.test.ts, autoinstall-cached-manifest, run-autoinstall-abs-path, run_command, workspaces, multi-run, self-reference, env.
  • Not changed here: devDependencies/optionalDependencies are still not read by this parser (it looks the sections up by the wrong key); that is a separate pre-existing bug affecting --filter ordering as well and is tracked separately.

Background

  • Auto-install: when a bare import cannot be found in any node_modules, the runtime resolver (load_node_modules) lazily creates a PackageManager and installs the package into the global cache. To pick a version it looks at dir_info.package_json_for_dependencies, the nearest cached package.json whose dependency map is non-empty; if the import is listed there, that entry's parsed version is what gets resolved, otherwise the specifier is parsed as the latest dist-tag.
  • DirInfo cache: every resolver in the process shares one cache of directory entries (DirInfo), each holding the interned parse of that directory's package.json. Whichever resolver touches a directory first decides what the cached entry contains; bun run creates several transpilers (and so several resolvers) before the runtime one.
  • GlobalCache / global_cache: the auto-install mode from --install (auto by default, disable for bundlers and tests). Resolver::use_package_manager() reads it to decide whether to record a package.json's dependencies at all; the per-call argument to resolve_and_auto_install decides whether a given resolution may install.
  • bun_resolver / bun_install layering: bun_install depends on the resolver crate, so the resolver reaches install-tier code either through the AutoInstaller trait object it is handed once a manager exists, or through extern "Rust" functions defined with #[no_mangle] in bun_install and resolved at link time.
  • SemverString / SlicedString: dependency names and versions are stored as 8 bytes, either the string inline (up to 8 bytes) or an offset+length into a buffer supplied when the string was created; reading one back requires the same buffer. SlicedString pairs that buffer with the sub-slice being parsed.

…package.json

Runtime auto-install resolved every bare import as the `latest` dist-tag
even when the project's package.json declared a range for the package.

Two things kept the package.json ranges from reaching the auto-installer:

- PackageJSON::parse could only parse dependency versions through the
  AutoInstaller vtable, and the project's package.json is parsed while
  resolving the entry point, before the first bare import creates the
  package manager. Without it every dependency was recorded with an
  uninitialized version, which the resolver treats as "not declared".
  Parsing now goes through a link-time hook into bun_install that does
  not need a manager (the manager is only used to record npm: aliases).

- The cwd's DirInfo is created by resolvers that have auto-install
  disabled (the VM transpiler's configure_linker() during init, and the
  configure_env_for_run transpiler used by `bun run <file>`), before the
  runtime's --install setting is applied, so the project's package.json
  was cached without its dependencies at all. The setting now reaches
  both before they read the directory (InitOptions::global_cache).

Also fix the parsed versions to be stored relative to the package.json
source buffer, which is what every reader of the dependency map slices
them with (specifiers longer than 8 bytes were read from the wrong
offset), and pass that buffer through lockfile_resolve so prerelease
ranges are compared against the buffer they were parsed from.
@coderabbitai

coderabbitai Bot commented Aug 13, 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: 54 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: 9f1de5b1-e25d-463e-9590-e0882ff89d2e

📥 Commits

Reviewing files that changed from the base of the PR and between b7a0431 and 1f2deae.

📒 Files selected for processing (10)
  • src/install/auto_installer.rs
  • src/install/lockfile.rs
  • src/install_types/resolver_hooks.rs
  • src/jsc/VirtualMachine.rs
  • src/resolver/package_json.rs
  • src/resolver/resolver.rs
  • src/runtime/cli/repl_command.rs
  • src/runtime/cli/run_command.rs
  • src/runtime/jsc_hooks.rs
  • test/cli/run/run-autoinstall.test.ts

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

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed, waiting on CI.

Reproduced with a local registry serving the no-deps fixture (1.0.0, 1.0.1, 1.1.0, 2.0.0, latest = 2.0.0) and a project declaring "no-deps": "^1.0.0": bun index.js installed and loaded 2.0.0 on bun 1.4.0 (and, from the project directory, on 1.3.14 as well; 1.3.14 only honored the range for a package.json below the cwd). With this branch every variant in the new describe block in test/cli/run/run-autoinstall.test.ts resolves the declared range (1.1.0 here); the same tests get 2.0.0 on the unfixed build.

Comment on lines 170 to 177
&self,
name: &[u8],
version: &hooks::DependencyVersion,
version_buf: &[u8],
) -> Option<PackageID> {
self.lockfile
.resolve_package_from_name_and_version(name, version)
.resolve_package_from_name_and_version(name, version, version_buf)
}

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.

🔴 resolve_from_disk_cache (called at resolver.rs:3669 for --install=offline) still hardcodes self.lockfile.buffers.string_bytes as the query's group_buf in PackageManagerResolution.rs:198-201, but after this PR the version there can be an Npm range whose prerelease/build tags are offsets into dependencies.source_buf. version_buf is already in scope and is threaded to the sibling lockfile_resolve (:3627) and enqueue_dependency_to_root (:3680) — the trait method / impl / call site should thread it here too.

Extended reasoning...

What the bug is

This PR fixes a class of bug where a DependencyVersion's prerelease/build-tag SemverStrings are sliced with the wrong backing buffer. It threads a new version_buf parameter through AutoInstaller::lockfile_resolve → Lockfile::resolve_package_from_name_and_version so that a query parsed from a package.json's raw bytes is compared using the buffer it was actually parsed against. However, the sibling trait method AutoInstaller::resolve_from_disk_cache was not updated: at src/install/PackageManager/PackageManagerResolution.rs:198-201 it still calls npm_query.version.satisfies(installed_version, self.lockfile.buffers.string_bytes.as_slice(), tags_buf.as_slice()), hardcoding the lockfile string buffer as the query's group_buf.

Code path

load_node_modules (resolver.rs:3021-3033) now clones the version straight out of package_json.dependencies.map, whose SemverStrings are offsets into dependencies.source_buf (the raw package.json bytes — see the new SlicedString::init(source_buf, version_str) in package_json.rs). It passes both version and version_buf = source_buf to enqueue_dependency_to_resolve (resolver.rs:3587, param at :3598). Inside that function, when install_preference == Offline at :3668, it calls pm!().resolve_from_disk_cache(esm.name, &version) at :3669 without version_buf — even though the two adjacent calls in the same function (lockfile_resolve at :3627, which this PR just fixed, and enqueue_dependency_to_root at :3680) both correctly thread version_buf.

Why this PR makes it reachable

Before this PR, the project's package.json was parsed before the PackageManager existed, so every entry was stored with tag == Uninitialized; load_node_modules re-parsed the specifier as the latest dist-tag, and resolve_from_disk_cache returned None at the tag != Npm early-exit (PackageManagerResolution.rs:169) — the wrong buffer was never read for that case. After this PR, r.parse_dependency stores real Npm-tagged versions, so the same range now reaches the satisfies call with offsets computed against source_buf but sliced from lockfile.buffers.string_bytes.

Step-by-step example

  1. package.json declares "pkg": "^1.0.0-alpha.beta.1" (prerelease tag alpha.beta.1 is 12 bytes, > 8, so stored as an offset into source_buf rather than inline).
  2. User runs bun --install=offline index.js with no node_modules; require("pkg") triggers auto-install.
  3. enqueue_dependency_to_resolve receives version (tag Npm, comparator prerelease tag = offset into source_buf) and version_buf = source_buf.
  4. install_preference == Offline, so :3669 calls resolve_from_disk_cache("pkg", &version).
  5. Group::satisfies (SemverQuery.rs) uses its group_buf argument to slice the query comparator's prerelease tag; here it receives self.lockfile.buffers.string_bytes.
  6. SemverString::slice bounds-checks and returns b"" on out-of-range, so instead of comparing against "alpha.beta.1" it compares against garbage or the empty string.
  7. Tag::order_without_build produces a wrong Ordering — e.g. treating the query's prerelease as empty — so a disk-cache version that does not satisfy ^1.0.0-alpha.beta.1 may be selected, or one that does may be rejected.

Impact

Narrow (offline auto-install with a >8-byte prerelease/build tag in the declared range) and non-crashing (SemverString::slice bounds-checks). But it produces a wrong satisfies() result — bun --offline can load the wrong version — and it is exactly the bug class the PR description names as a fix ("prerelease tags in a range would be read from the wrong buffer"). Per REVIEW.md, "Fix the whole class in the same PR" — sibling sites sharing the pattern are one concern.

Fix

Add version_buf: &[u8] to the AutoInstaller::resolve_from_disk_cache trait method (resolver_hooks.rs), the impl in auto_installer.rs, the free-function shim and inherent method in PackageManagerResolution.rs, and pass it as the second argument to satisfies at :198. Then pass version_buf at the call site resolver.rs:3669. This is mechanically identical to what the PR already did for lockfile_resolve.

Comment on lines +909 to +917
// Like the key above: a value with JSON escapes is
// decoded outside the source buffer and cannot be
// stored as an offset into it.
if !bun_alloc::is_slice_in_buffer(
version_str,
package_json.dependencies.source_buf,
) {
continue;
}

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.

🟡 The new is_slice_in_buffer(version_str, source_buf) guard does continue when a version string contains JSON escapes (e.g. a Windows "file:C:\\\\Users\\\\foo" path), dropping the dependency key from the map entirely — the old code still recorded it (with an Uninitialized version, per the deleted "bun run --filter reads only the map keys" comment), so filter_run.rs:914-920 loses that edge from the workspace ordering graph. Consider inserting with DependencyVersion::default() instead of continue to preserve the key while keeping the buffer-offset guard.

Extended reasoning...

What the bug is

The PR adds a value-side is_slice_in_buffer guard at src/resolver/package_json.rs:909-917 that mirrors the existing key-side guard: when a dependency version string contains JSON escapes (\\, \", \n, \uXXXX), the JSON parser decodes it into a temporary outside source_buf, so the check fails and the loop does continue. This is correct for the version — a SlicedString over an out-of-buffer string cannot be stored as an offset into source_buf — but it also drops the key, which has already passed its own is_slice_in_buffer guard and is perfectly storable.

The specific code path

src/runtime/cli/filter_run.rs:840 calls PackageJSON::parse::<{IncludeDependencies::Main}> directly, and at lines 913-920 it reads only pkgjson.dependencies.map.keys() to build each workspace's deps: Vec<Box<[u8]>> — the values are never touched. Those keys are what the topological sort uses to decide script execution order across --filtered workspaces.

Why existing code doesn't prevent it

Before this PR, the loop unconditionally reached the insert:

  • With no auto-installer (the filter_run.rs case — its transpiler has global_cache = disable), the old code hit None => Some(DependencyVersion::default()), whose deleted comment explicitly said "bun run --filter reads only the map keys to compute workspace ordering".
  • With an auto-installer, the old code used SlicedString::init(version_str, version_str) (wrong buffer for the value — which this PR fixes — but the key still landed in the map).

Either way the key was recorded. Now it is not.

Step-by-step example

  1. On Windows, a workspace packages/app/package.json declares "dependencies": { "shared": "file:C:\\\\ws\\\\shared" }. JSON.stringify writes the backslashes as \\, so the raw file bytes contain \\ escape sequences.
  2. bun run --filter '*' build reaches filter_run.rs:840, which calls PackageJSON::parse::<Main> on packages/app.
  3. In the dependency loop, name_str = "shared" passes its is_slice_in_buffer guard (no escapes in the key). version_str = "file:C:\\ws\\shared" was decoded by the JSON parser into a temporary, so is_slice_in_buffer(version_str, source_buf) returns false → continue.
  4. "shared" is never inserted into package_json.dependencies.map.
  5. Back in filter_run.rs:914-920, deps for app is [] instead of ["shared"].
  6. The topological sort no longer knows app depends on shared, so app's build may run before (or concurrently with) shared's.

Impact

bun run --filter executes workspace scripts in the wrong order when a workspace-to-workspace dependency is spelled with a version string containing JSON escapes. Auto-install itself is unaffected (a missing key and an Uninitialized-tag value both fall back to latest, and file:/link: never auto-installed anyway per the PR's own behaviour note).

The trigger is narrow — realistically only Windows file: paths written with backslashes; workspace:*, ^1.0.0, file:../shared, and forward-slash Windows paths contain no JSON escapes — hence nit. But the deleted comment shows the old fallback was intentional, and REVIEW.md flags "before deleting odd-looking code, git-blame why it was written — it is usually load-bearing."

How to fix

Replace the value-side continue with an insert using DependencyVersion::default() (the Uninitialized tag), preserving the key for --filter while keeping the buffer-offset guard:

if !bun_alloc::is_slice_in_buffer(version_str, package_json.dependencies.source_buf) {
    // Value has JSON escapes and cannot be stored as an offset into
    // source_buf; still record the key so `bun run --filter` sees the edge.
    let dependency = Dependency {
        name,
        version: DependencyVersion::default(),
        name_hash,
        behavior: group.behavior,
    };
    // ... same put_assume_capacity_context as below ...
    continue;
}

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:25 PM PT - Aug 13th, 2026

✅ @robobun, your commit 1f2deae162c3cb45ef4c76e8db4d158db5905b4e passed in Build #94738! 🎉


🧪   To try this PR locally:

bunx bun-pr 38198

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

bun-38198 --bun

@robobun

robobun commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: superseded by #42839 (the version buffer) and #43984 (the package.json range, parsed on demand after the package manager exists, so the cwd read by configure_linker no longer matters). The test cases here are covered there.

@robobun robobun closed this Sep 25, 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.

1 participant