Skip to content

install: write the resolved version into the lockfile for bun update - #33127

Closed
robobun wants to merge 17 commits into
mainfrom
farm/d1145ba8/update-latest-lockfile-literal
Closed

robobun wants to merge 17 commits into
mainfrom
farm/d1145ba8/update-latest-lockfile-literal

Conversation

@robobun

@robobun robobun commented Jun 30, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #13388

bun update writes the resolved version range into package.json, but bun.lock's workspaces section kept a different string, so the very next bun install rewrote the lockfile.

With no package names, the lockfile kept the literal from the pre-resolution in-memory package.json: the string latest under --latest, the stale old range otherwise.

echo '{"name":"t","dependencies":{"is-odd":"^1.0.0"}}' > package.json
bun install
bun update --latest
grep is-odd package.json bun.lock
# package.json:    "is-odd": "^3.0.1"
# bun.lock:        "is-odd": "latest",
bun install   # "Saved lockfile": rewrites bun.lock again

With package names, the lockfile got ^<resolved> regardless of the user's pin level, and for npm: aliases it dropped the alias target:

echo '{"name":"t","dependencies":{"odd":"npm:is-odd@~1.0.0"}}' > package.json
bun install && bun update odd
# package.json:   "odd": "npm:is-odd@~1.0.2"
# bun.lock:       "odd": "^1.0.2",     # tilde and "npm:is-odd@" gone

And bun update <name>@<version> did not rewrite the lockfile at all, so it kept the requested range while package.json got the resolved one:

echo '{"name":"t","dependencies":{"is-odd":"^1.0.0"}}' > package.json
bun install && bun update is-odd@^3.0.0
# package.json:   "is-odd": "^3.0.1"
# bun.lock:       "is-odd": "^3.0.0",

Cause

The lockfile's root dependency literals are rewritten before the save, but only for requests in manager.update_requests (CLI positionals), by Lockfile::preprocess_update_requests. Three gaps:

  • bun update with no package names tracks its targets through manager.updating_packages instead, which nothing preprocessed.
  • preprocess_update_requests itself hardcoded ^<resolved> and ignored the alias prefix. The TODO(dylan-conway) comment it carried described exactly this.
  • preprocess_update_requests only rewrote dist-tag requests (bun update <name>, whose positional parses as <name>@latest). A request with an explicit range parses as Npm and was skipped, even though PackageJSONEditor::edit's post-install gate rewrites package.json for it.

In each case package.json got the correct pin-preserving literal from a separate computation in PackageJSONEditor, so the two files could not agree.

Fix

One formatter, format_updated_version_literal in lockfile.rs: the resolved version at the same pin level (^, ~, exact) as the user's literal, with the npm:<name>@ prefix preserved for aliases. It is the previously inline block in PackageJSONEditor::edit, extracted. Both the pin and the prefix are derived from the recorded original package.json literal; the lockfile side previously read the prefix from the lockfile dependency's own literal, which a positional bun update <name>@<version> has already replaced with the prefix-less request, so an npm: alias under that spelling still lost its prefix in bun.lock. Three paths go through the formatter:

  • Lockfile::preprocess_updating_packages (new): the no-argument path, keyed off manager.updating_packages, mirroring preprocess_update_requests. It writes the literal into the lockfile's root dependency and also records it on the PackageUpdateInfo entry; the post-install package.json edit now applies that recorded literal instead of re-deriving it. It cannot re-derive it anymore, because the lockfile dependency it used to read was just rewritten (concretely, its is_exact_npm guard evaluated on the rewritten dependency would regress bun update with install.exact = true).
  • preprocess_update_requests (positional): when updating_packages has an entry for the dependency (only bun update <name> records one), use the shared formatter; otherwise keep saving ^<resolved>, with the request's npm:<target>@ prefix when the request is an alias. A new dependency added through bun update <name>@npm:<target>@<range> has no entry, and PackageJSONEditor::edit's matching no-entry branch keeps that prefix, so the lockfile must too, or the alias would be silently dropped from both files. The TODO comments are removed. Its request gate now mirrors PackageJSONEditor::edit's post-install gate instead of accepting only dist-tags: a dist-tag request always rewrites, and an Npm range does so only under bun update, which is what fixes bun update <name>@<version>. The Subcommand::Update check is load-bearing: bun add <name>@<range> goes through the same function with no updating_packages entry, already saves the requested range to both files, and relaxing the guard to accept Npm unconditionally would have flipped its lockfile entry to the ^<resolved> fallback.
  • PackageJSONEditor::edit's post-install package.json write: the inline twin becomes a call to the helper. Its output is byte-identical.

PackageUpdateInfo gains a group_behavior field so the lockfile dependency is matched by name and dependency group, not name alone: with the same name in two groups, bun update only moves one group in package.json, and the lockfile must leave the other group's entry alone too.

Skipping catalog: literals in the new rewrite also means bun update with no arguments no longer replaces a root "catalog:" entry in package.json with the resolved range, consistent with the existing test "should support catalog versions in update".

Verification

  • test/cli/install/bun-update.test.ts: 14 new cases. Three assert that package.json and bun.lock agree on a plain and an npm:-aliased dependency after bun update --latest, bun update, and bun update <names>, and that the following bun install has nothing left to save. Seven cover an explicit positional version: bun update <name>@<range> and bun update <name>@<exact> of a recorded dependency re-pin from the original literal, bun update <name>@<range> of an unrecorded one falls back to ^<resolved> in both files, the two npm: alias forms of that spelling for an existing alias (the full npm:<target>@<range> spec and a self-alias with a bare range, the one shape where the alias name itself resolves), bun update <name>@npm:<target>@<range> adding a new alias (which must keep the npm: prefix in both files), and bun add <name>@<range> still keeps the requested range unchanged. Three are the two-group variant, and one covers a versionless scoped npm: alias. 12 of the 14 fail on bun 1.4.0; the bun add case and the scoped-alias case are deliberate non-regression guards for the gate change and for the group-matching logic.

  • A 15-case matrix (caret, tilde, exact, dist-tag, alias, two groups, root catalog, explicit positional version, a new alias via a positional update, each crossed with --latest, plain, and positional) against the npm registry produces byte-identical package.json output before and after the change, and agreeing lockfiles only after. A separate alias probe (bun update <alias>@npm:<target>@<range>, the self-alias form, and bun update <new-name>@npm:<target>@<range>) against the npm registry shows both files agreeing with no re-save.

  • test/cli/install/lockfile-sync.test.ts (new): a 260-case matrix covering {bun add, bun remove, bun update, bun update --latest, bun update <names>} x {dependencies, devDependencies, optionalDependencies, peerDependencies} x {npm, npm alias, folder, link:, local tarball, remote tarball, workspace:} x {new, same, greater, lower resolution}, plus every pin style which_version_is_pinned distinguishes, install.exact, a dependency added to package.json but not yet in the lockfile, and the same name in two dependency groups across all six group pairs. Every cell asserts that package.json and bun.lock's root workspace entry carry identical literals and that the next bun install has nothing left to save; the non-npm protocols are the negative contract (their literals must survive every operation untouched in both files). 99 of the 260 fail on bun 1.4.0, all of them bun update flavors; the remaining 161 pin down the bun add, bun remove, and non-npm behavior that already held.

robobun added 2 commits June 30, 2026 00:01
…ith no package names

`bun update` and `bun update --latest` with no package names write the
resolved version range into package.json, but the lockfile's workspaces
section kept the literal from the pre-resolution in-memory package.json
(the string "latest" under --latest, the stale range otherwise). The very
next `bun install` then rewrote bun.lock.

The positional path already handles this in
Lockfile::preprocess_update_requests, keyed off manager.update_requests.
The no-argument path tracks its targets through manager.updating_packages
instead, so nothing rewrote the root dependency literals before the
lockfile was saved.

Add Lockfile::preprocess_updating_packages to do the same rewrite for
that path. It computes the final literal once, writes it into the old
lockfile's root dependency (which clean() clones into the saved
lockfile), and records it on the PackageUpdateInfo entry. The post-
install package.json edit now applies that recorded literal instead of
re-deriving it from the lockfile, which it can no longer do because the
lockfile dependency it would read was just rewritten.

The per-group Behavior is recorded on PackageUpdateInfo so the lockfile
dependency is matched by name and group, not name alone.

catalog: literals are skipped by the new rewrite, so `bun update` with
no arguments no longer replaces a root "catalog:" entry in package.json
with the resolved range.
…bun update

The positional path, Lockfile::preprocess_update_requests, wrote
^<resolved> into the lockfile's root dependency regardless of the
user's pin level, and for npm: aliases it dropped the alias target
entirely (`bun update odd-alias` on "npm:is-odd@~1.0.0" left the
lockfile saying odd-alias: "^1.0.2"). package.json got the correct
literal from the twin of format_updated_version_literal that lived
inline in PackageJSONEditor::edit, so the two files disagreed and the
next bun install rewrote the lockfile.

Route both through format_updated_version_literal: the new
positional_update_literal helper uses it when `bun update <name>`
recorded the original literal in updating_packages, and keeps the
existing ^<resolved> for `bun add` (which records nothing). The
inline twin in PackageJSONEditor::edit is replaced with a call to the
shared helper; its output is byte-identical, verified against the
unfixed binary.
@coderabbitai

coderabbitai Bot commented Jun 30, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

bun update now records reusable version literals, applies shared formatting in lockfile and package.json update paths, and adds regression coverage for update, add, and remove flows that keep package.json and bun.lock aligned.

Changes

bun update lockfile synchronization

Layer / File(s) Summary
PackageUpdateInfo contract extension
src/install/PackageManager.rs
PackageUpdateInfo imports Behavior and adds group_behavior and updated_version_literal fields.
Lockfile preprocessing and formatting
src/install/lockfile.rs
lockfile.rs adds shared literal-formatting helpers, updates root dependency rewrite handling, stores precomputed literals on update entries, and changes cold preprocessing dispatch.
PackageJSONEditor update paths
src/install/PackageManager/PackageJSONEditor.rs
PackageJSONEditor imports shared formatting, records update metadata, removes inline literal computation from edit_update_no_args, and uses the shared formatter in edit.
bun update regressions
test/cli/install/bun-update.test.ts
Adds a shared command helper and regressions for alias literals, dependency-group selection, and lockfile stability after bun update.
lockfile sync coverage
test/cli/install/lockfile-sync.test.ts
Adds an in-process registry harness, shared project helpers, and broad end-to-end coverage for update, add, and remove flows that keep package.json and bun.lock aligned.

Possibly related PRs

  • oven-sh/bun#30935: Both PRs touch src/install/PackageManager/PackageJSONEditor.rs update-time dependency group scanning and related rewrite logic in the same install/update path.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: syncing resolved versions into the lockfile during bun update.
Linked Issues check ✅ Passed The changes address #13388 by writing the same resolved literals to package.json and bun.lock so bun install no longer rewrites it.
Out of Scope Changes check ✅ Passed The added helpers and tests support the update/lockfile sync fix and stay within the linked issue's scope.
Description check ✅ Passed The PR description clearly explains the bug, fix, and verification, even though it uses different headings than the template.

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

@robobun

robobun commented Jun 30, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:12 PM PT - Jul 1st, 2026

❌ @robobun, your commit c092898 has 4 failures in Build #67765 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33127

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

bun-33127 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. bun update --latest doesn't follow exact versions syntax of npm update --save #19091 - bun update --latest ignores exact version pins because the lockfile always wrote ^<resolved> regardless of the original pin specifier
  2. 'bun update --latest' Downgrades Canary and Next Packages to Stable Versions #16569 - bun update --latest downgrades canary/next packages to stable because the pre-release range is erased when writing ^<resolved> to the lockfile

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #19091
Fixes #16569

🤖 Generated with Claude Code

@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

Checked both. Neither is fixed here, so I am not adding the Fixes lines.

This PR only changes what gets written into bun.lock. The package.json output of bun update is unchanged byte for byte (verified across caret, tilde, exact, dist-tag, alias, and positional variants), and both of those issues are about package.json:

The causal framing in the suggestion (the lockfile writing ^<resolved>) describes the bug this PR does fix, but neither of those two issues is an instance of it.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Re-sync $pkg overrides after bun update (no args) #31752 - Both fix the same bug where bun update (no args) leaves stale version literals in the lockfile's workspace section; Re-sync $pkg overrides after bun update (no args) #31752 introduces the same preprocess_updating_packages method and pin-style preservation, while install: write the resolved version into the lockfile for bun update #33127 is a superset that also handles aliases, positional bun update <name>, and unifies the formatter

🤖 Generated with Claude Code

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/install/lockfile.rs`:
- Around line 1454-1464: The fallback version rewrite in
`format_updated_version_literal` is too broad and can rewrite a dependency from
the wrong group when `updating_packages` has a matching name but `dep.behavior`
does not intersect `entry.group_behavior`. Update
`format_updated_version_literal` so it returns `None` on behavior mismatch, and
only emit the default `^<resolved>` literal when no `updating_packages` entry
exists; then adjust both `preprocess_update_requests` loops to `continue`
whenever this helper returns `None`, keeping `PackageJSONEditor` and lockfile
updates aligned for duplicate dependency names across groups.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 883a9d71-ee5b-4447-9de0-8ac9400ff1d3

📥 Commits

Reviewing files that changed from the base of the PR and between 459c33f and c069af9.

📒 Files selected for processing (4)
  • src/install/PackageManager.rs
  • src/install/PackageManager/PackageJSONEditor.rs
  • src/install/lockfile.rs
  • test/cli/install/bun-update.test.ts

Comment thread src/install/lockfile.rs Outdated
@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

Looked at #31752's diff, not just its description: not a duplicate.

#31752 fixes #31748, where a $pkg self-referential entry in the lockfile's overrides map keeps the pre-update literal after bun update. Its one new function, refresh_self_referential_overrides, only rewrites lockfile.overrides.map entries. The description there mentions a preprocess_updating_packages, but no such function exists in that diff; the suggestion above appears to have matched on the description.

This PR fixes the root package's dependency literals in the lockfile's workspaces section. Neither PR touches the other's data, so both are needed. They do collide textually, since both add code to src/install/lockfile.rs adjacent to preprocess_update_requests, so whichever lands second needs a small rebase. #31752's inline which_version_is_pinned plus ^/~/exact block could also reuse format_updated_version_literal from here once this lands.

… did not touch

When the same name appears in more than one dependency group,
`bun update <name>` records a single updating_packages entry, from the
first group PackageJSONEditor::edit scans, and only rewrites that group
in package.json. positional_update_literal fell through to the default
`^<resolved>` for the lockfile dependency belonging to the other group,
changing its pin level while package.json kept the original, so the two
files still disagreed there. Return None for that case so
preprocess_update_requests leaves the dependency alone.
Comment thread src/install/lockfile.rs Outdated

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/install/lockfile.rs (1)

921-934: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Trim these comments to the 3-line limit.

These new comments exceed the repo’s max comment length. Keep only the invariant: the lockfile rewrite records the literal that package.json later consumes. As per coding guidelines, “Keep code comments to 3 lines max.”

Also applies to: 1194-1198, 1451-1455

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/install/lockfile.rs` around lines 921 - 934, Trim the new block comments
in lockfile.rs to the repo’s 3-line limit by removing the long explanation and
keeping only the core invariant. Update the comments around the logic in the
relevant lockfile rewrite path so they state that the lockfile rewrite records
the literal that package.json later consumes, without describing the full bun
update flow or post-install timing details. Apply the same shortening pattern to
the other affected comment blocks referenced near the lockfile update helpers
and PackageUpdateInfo handling.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/install/lockfile.rs`:
- Around line 829-837: The positional rewrite path in lockfile updates is still
rewriting root dependencies backed by catalog: literals, unlike the no-args
path. Add the same catalog: guard used elsewhere before calling
positional_update_literal in the positional update flow so root literals remain
unchanged for bun update <name>; check the logic around
manager.updating_packages, dep, and exact_versions where the new literal is
computed.

In `@test/cli/install/bun-update.test.ts`:
- Around line 367-372: Shorten the test comment to 3 lines or fewer and keep it
behavior-focused only. Remove implementation-specific references like
edit_update_no_args and edit(), and rewrite it to describe the durable
observable behavior in bun update: when the same package name exists in multiple
dependency groups, only one matching entry is moved in package.json and bun.lock
must keep the untouched group entry aligned. Use the existing comment block in
bun-update.test.ts as the location to update.

---

Outside diff comments:
In `@src/install/lockfile.rs`:
- Around line 921-934: Trim the new block comments in lockfile.rs to the repo’s
3-line limit by removing the long explanation and keeping only the core
invariant. Update the comments around the logic in the relevant lockfile rewrite
path so they state that the lockfile rewrite records the literal that
package.json later consumes, without describing the full bun update flow or
post-install timing details. Apply the same shortening pattern to the other
affected comment blocks referenced near the lockfile update helpers and
PackageUpdateInfo handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 30e6bffd-fccf-4b32-8afb-2e376119f5b5

📥 Commits

Reviewing files that changed from the base of the PR and between c069af9 and bfa0679.

📒 Files selected for processing (2)
  • src/install/lockfile.rs
  • test/cli/install/bun-update.test.ts

Comment thread src/install/lockfile.rs Outdated
Comment thread test/cli/install/bun-update.test.ts Outdated

@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 issues found on the current revision — my earlier note and CodeRabbit's are both addressed. Deferring to a human reviewer since this restructures the data flow between lockfile preprocessing and PackageJSONEditor (the no-arg post-install edit now depends on preprocess_updating_packages having populated updated_version_literal), which is subtle enough in the install path to warrant a maintainer's look.

Extended reasoning...

Overview

This PR touches src/install/lockfile.rs, src/install/PackageManager.rs, src/install/PackageManager/PackageJSONEditor.rs, and adds tests in test/cli/install/bun-update.test.ts. It fixes #13388 by making bun update write the same resolved version literal into bun.lock's workspace section that it writes into package.json. The fix introduces a shared formatter (format_updated_version_literal), a new lockfile preprocessing pass for the no-argument path (preprocess_updating_packages), extends PackageUpdateInfo with group_behavior and updated_version_literal, and refactors PackageJSONEditor::edit_update_no_args to consume the precomputed literal instead of re-deriving it.

Security risks

None identified. The change is confined to how version-range strings are formatted and threaded between in-memory lockfile state and the package.json editor. No auth, network, filesystem-permission, or untrusted-input parsing surfaces are introduced.

Level of scrutiny

Moderate-to-high. While the diff is well-scoped and well-tested (6 new parameterized regression cases against a dummy registry, plus a 13-case matrix verified by the author), it is a non-trivial refactor of core package-manager state flow:

  • edit_update_no_args's post-install branch drops ~100 lines of inline derivation and now relies on preprocess_updating_packages having already populated entry.updated_version_literal. The PR description itself flags that re-deriving here would regress install.exact = true because the lockfile dependency it would read has just been rewritten — i.e., there is a new, load-bearing ordering dependency.
  • The else if !manager.updating_packages.is_empty() in clean_with_logger is intentionally exclusive with the positional path; the comment explains why, but a maintainer familiar with the install pipeline should confirm the call ordering holds across all entry points (workspaces, --filter, etc.).
  • Three rounds of bot review surfaced real edge cases (group-behavior mismatch in the positional path) that were fixed iteratively, which suggests the state space is subtle.

Other factors

  • All inline review threads (mine and CodeRabbit's) are resolved as of 14f517d; the catalog-guard suggestion was correctly rejected with a verified counterexample.
  • No CODEOWNERS entry covers src/install/.
  • The bug-hunting system found nothing on the current revision.
  • CI on bfa0679 had unrelated build-rust failures on freebsd/android; status on 14f517d is not yet reflected in the timeline.
  • The removed code carried a TODO(dylan-conway) describing exactly this fix, so a quick look from someone familiar with that TODO's intent would be ideal.

@robobun

robobun commented Jun 30, 2026 •

Copy link
Copy Markdown
Collaborator Author

Closed: the whole bun update lockfile-staleness family is being fixed together in one package-manager PR, tracked under #13388 (see the closing comment below). The diff and tests here were input to that; the handoff notes are in my last comment.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you consolidate more of this code?

preprocess_update_requests and preprocess_updating_packages each
hand-rolled the same two-pass StringBuilder protocol (size, allocate,
append, re-parse, clamp) with the matching split-borrow setup, so the
protocol existed twice. Collapse it into one
apply_root_dependency_literals; each caller is now a single decision
loop that collects (root dependency index, new literal) pairs. The two
single-line cold wrappers around them become one dispatch.
@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

Done in 57d9eb4.

preprocess_update_requests and the new preprocess_updating_packages each hand-rolled the same two-pass StringBuilder protocol (size, allocate, append, re-parse, clamp) with the matching split-borrow setup, so the protocol existed twice. That is now one apply_root_dependency_literals, and each of the two is reduced to a plain loop that decides which root dependencies to rewrite and collects (index, new literal) pairs. The two single-line #[cold] wrappers around them also collapse into one dispatch.

Together with the earlier rounds, the version-literal formatting that previously existed in three byte-for-byte copies (the positional lockfile rewrite, the no-argument lockfile rewrite, and the inline block in PackageJSONEditor::edit) is now the single format_updated_version_literal, and the src/install portion of the PR is net negative, +298/-334.

Behavior is unchanged: all 12 bun-update.test.ts cases pass with the build and the 8 new ones still fail without the src/ change, and a 22-case matrix against the npm registry (caret, tilde, exact, dist-tag, alias, catalog, and a name in two groups, crossed with --latest, plain, and positional bun update) produces output identical to the pre-consolidation build, with package.json identical to an unmodified bun.

One smaller spot remains if you want it gone too: PackageJSONEditor::edit's post-install arm keeps its own ^<resolved> fallback for a request with no recorded original literal (the bun add path), which is the same few lines as positional_update_literal's fallback. Folding them together moves one more helper across the lockfile/editor boundary, so I left it alone, but I am happy to do it.

Comment thread src/install/lockfile.rs
robobun and others added 2 commits July 1, 2026 05:28
test/cli/install/lockfile-sync.test.ts asserts, for every cell, that after
the operation package.json and the root workspace entry of bun.lock carry
the same literals, and that the next `bun install` has nothing left to save.

Dimensions: {bun add, bun remove, bun update, bun update --latest,
bun update <names>} x {dependencies, devDependencies, optionalDependencies,
peerDependencies} x {npm, npm alias, folder, link:, local tarball, remote
tarball, workspace:} x {new, same, greater, lower resolution}. On top of
that: every pin style `which_version_is_pinned` distinguishes, install.exact,
a dependency added to package.json but not yet in the lockfile, and the same
name in two dependency groups across all six group pairs.

The non-npm protocols are the negative contract. `bun update` only rewrites
npm resolutions, so those literals must come through every operation
byte-identical in both files.

260 tests, each project on its own namespace of one in-process registry so
the whole file runs concurrently. 99 fail on bun 1.4.0 (every `bun update`
flavor); the remaining 161 pin down add, remove, and non-npm behavior that
already held.

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/cli/install/lockfile-sync.test.ts`:
- Around line 1-21: The top-of-file comment in lockfile-sync.test.ts is too long
and includes bug history that should not live in the test. Shorten the block
around the existing issue URL so it only states the invariant needed for the
test, and remove the matrix/history details while keeping the intent clear for
the test suite.
- Around line 23-64: This install suite is missing the standard long-running
test timeout, so add the established 5-minute default timeout at the top of this
file before the async setup runs. Use the same install-test pattern as other
`test/cli/install/*` specs so the `beforeAll`/subprocess-heavy tests don’t hit
the default Bun timeout during package manager operations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1d58b34a-0fff-4000-acbd-17552a12cc2d

📥 Commits

Reviewing files that changed from the base of the PR and between 14f517d and 9db4af5.

📒 Files selected for processing (2)
  • src/install/lockfile.rs
  • test/cli/install/lockfile-sync.test.ts

Comment thread test/cli/install/lockfile-sync.test.ts Outdated
Comment thread test/cli/install/lockfile-sync.test.ts Outdated
Every other test/cli/install suite calls setDefaultTimeout(5 minutes)
because the package manager subprocesses it spawns exceed the default
per-test timeout under a debug build. Also trim the file comment to the
repo's limit.
Comment thread test/cli/install/lockfile-sync.test.ts
Every spawned bun shared one BUN_INSTALL_CACHE_DIR while the suite runs
its ~260 projects concurrently, and every project carries the same local
tarball, so they all extract into one cache slot. On Windows that race
fails the whole install: the loser of the rename gets
  moving "tgz-local-dep" to cache dir failed ENOTEMPTY
  (NtSetInformationFile())
and a concurrent reader gets
  ENOENT: failed opening cache/package/version dir
Point each bun at a cache inside its own project directory so no two
processes ever share a cache slot, and drop the now unused shared
directory.

Also trims the remaining comments in the file to the repo's 3-line
limit.
Comment thread src/install/lockfile.rs Outdated
Comment thread test/cli/install/lockfile-sync.test.ts Outdated
PackageJSONEditor::edit records a default-initialized entry for a
positional target whose npm: alias version tag it cannot classify. For
a versionless scoped alias like "npm:@scope/pkg" the only @ is the
scope's, the re-check infers a non-npm tag from "scope/pkg" and bails
after get_or_put already inserted the slot, so the entry's
group_behavior stays empty. intersects(empty()) is always false, which
made positional_update_literal skip the lockfile rewrite while the
post-install package.json edit still wrote ^<resolved>:

  {"foo": "npm:@isaacs/string-locale-compare"}; bun update foo
  package.json: ^1.1.0    bun.lock: npm:@isaacs/string-locale-compare

An empty group means no group was recorded, not a mismatch, so it takes
the ^<resolved> fallback, matching the package.json edit.

Also replaces lockfile-sync.test.ts's hand-rolled JSONC parser with
Bun.JSONC.parse.
Comment thread src/install/lockfile.rs Outdated
robobun added 3 commits July 1, 2026 21:41
Lockfile::preprocess_update_requests only rewrote the root dependency
for a dist-tag request, but PackageJSONEditor::edit's post-install gate
also rewrites package.json for an Npm-range request under bun update,
so the two files disagreed and the next bun install re-saved:

  {"is-odd": "~1.0.0"}; bun update is-odd@^3.0.0
  package.json: ~3.0.1   (re-pinned from the original literal)
  bun.lock:     ^3.0.0   (the request literal)

Mirror the editor's gate: a dist-tag request always rewrites; an npm
range does so only under bun update, for a dependency whose original
literal was recorded or a non-exact request. bun add <name>@<range>
records nothing and is not the Update subcommand, so it keeps writing
the requested range into both files unchanged.
Comment thread src/install/lockfile.rs
format_updated_version_literal read the alias prefix from a per-caller
dep_literal argument but the pin level from entry.original_version_literal.
For a positional `bun update <name>@<version>` of an `npm:` alias, the
pre-install package.json edit replaces the in-memory literal with the
requested version, so the lockfile caller's dep_literal had lost the
`npm:<target>@` prefix and the two files disagreed. Derive both from
entry.original_version_literal and delete the parameter, so every caller
formats identically.
Comment thread src/install/lockfile.rs Outdated
…fallback

`bun update <name>@npm:<target>@<range>` for a `<name>` not yet in
package.json records no `updating_packages` entry, so the lockfile
rewrite took the `^<resolved>` fallback and dropped the `npm:<target>@`
prefix. `clean_with_logger` then copies the rewritten root dependency
back into the request, so the post-install package.json edit no longer
saw an alias either and both files saved a bare range, silently losing
the alias. `PackageJSONEditor::edit`'s matching no-entry branch keeps
the prefix from the request literal, split at the first `@`, and the
root dependency still carries that literal when the fallback runs, so
mirror it there. The prefix is scoped to the no-entry arm: an existing
entry that `edit` could not classify takes `edit`'s entry path, which
carries no prefix, and must keep matching it.
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Closing this one: all of the bun update lockfile-staleness symptoms (declared ranges / pin style / npm: aliases / catalog entries / $ref overrides staying stale in bun.lock because it is saved before the package.json write-back, plus the stale nested copies after a direct-dep bump) share a root cause, so we're fixing the whole family together in one package-manager PR that's about to go up, tracked under #13388. The diff and tests here were used as input for that — thank you. (This comment was written by Claude, on behalf of the Bun team.)

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Sounds good, a single fix for the whole family is the better shape. Closing out here.

In case it saves the consolidated PR a round of review, these are the cases that were not visible from the original symptom and each cost this PR a revision. All of them have a pinned literal in test/cli/install/bun-update.test.ts on this branch, and test/cli/install/lockfile-sync.test.ts is a 260-case add/remove/update x group x protocol matrix that should apply to the new fix unchanged:

  • bun add <name>@<range> goes through the same positional rewrite as bun update <name>. Relaxing the dist-tag-only guard without gating on the update subcommand flips bun add's lockfile entry from the requested range to ^<resolved>.
  • Adding a new alias with bun update <name>@npm:<target>@<range>: the rewritten lockfile dependency is copied back into the request before the package.json write-back, so a prefix dropped in the lockfile is dropped from package.json too. Both files then agree on the wrong literal, which means a "next bun install is a no-op" assertion alone cannot catch it; tests need to assert the literal itself.
  • For an explicit positional version on an existing npm: alias, the in-memory package.json literal has already been replaced with the prefix-less request by the time the lockfile is built, so the alias prefix has to come from the literal recorded before the edit, not from the dependency being rewritten.
  • With install.exact, the post-install package.json edit cannot re-derive its literal from the lockfile dependency once that dependency has been rewritten (the exact-version guard evaluates against the rewritten value); the literal has to be computed once and applied to both files.
  • The same name in two dependency groups: the lockfile rewrite has to match on group, not name, or it moves the group that bun update left alone in package.json.
  • A versionless scoped alias ("x": "npm:@scope/pkg") under positional update leaves a recorded entry with no group, which is not the same thing as a group mismatch.
  • On Windows, the concurrent matrix needs a per-project BUN_INSTALL_CACHE_DIR; projects sharing one cache race on the same tarball slot. Separately, one CI run of that matrix had Bun.file().text() return a different file's contents (and another throw EBADF) under concurrent reads on Windows, which looks like a runtime issue independent of any of this.

Jarred-Sumner added a commit that referenced this pull request Aug 14, 2026
…ilter/--catalog, nested overrides, transitive update, and workspace fixes (#38333)

Brings Bun's package manager to parity with pnpm for monorepo workflows,
and fixes the bugs found while checking every command against pnpm's
implementation, pnpm's test suites, pnpm's open issue tracker, pnpm's
docs, npm's arborist fixtures, and — for `bun update` — running real
pnpm and Bun side by side on the same projects.

### What does this PR do?

#### New commands

- **`bun dedupe [--check]`** — collapses duplicate versions in
`bun.lock` onto the smallest set that still satisfies every dependent's
range, using only versions already in the lockfile, then installs. Never
downgrades a direct dependency unless that is the only way to drop a
version; keeps patched versions (and anything needed to reach them, and
says so); refuses to run on a lockfile that is behind `package.json`.
`--check` exits 1 without writing.
- **`bun prune [--production | --omit=…] [--dry-run] [--filter <ws>]`**
— removes everything in `node_modules` that the lockfile does not put
there; `--production` leaves exactly what `bun install --production`
would. Hoisted and isolated layouts, Windows junctions and shims,
workspace links, bundled deps; refuses when `package.json` and
`bun.lock` disagree or when `node_modules` was laid out by a different
linker; understands turbo-pruned checkouts.
- **`bun pm licenses [--json] [--prod|--dev] [--long] [--filter <ws>]`**
— installed packages grouped by license, with a `(dev)` marker and
`paths`/`license`/`description` in `--json`.
- **`bun audit fix [--latest] [--dry-run] [--json]`** — moves each
vulnerable package to the lowest safe version its dependents accept, per
installed instance; rewrites exact pins when that is the only way;
`--latest` also rewrites your own declared ranges (root, workspace,
catalog) so a semver-major fix can be taken, and every blocked or
unfixable item is followed by the command that resolves it (`bun audit
fix --latest`, `bun audit --ignore GHSA-…`); re-audits the tree it
actually installed and reports/exits from that second response (npm's
`_submitQuickAudit`), so an advisory that starts at the version it moved
to is not missed; works across registries; security fixes bypass
`minimumReleaseAge` with an annotation. `bun audit --json` honors
`--audit-level`/`--ignore` for its exit code; `--omit` is honored by
`audit` and `licenses`.

#### `bun update` semantics (pnpm's model)

- A bare `bun update` re-resolves **transitive** packages too — every
edge moves to the newest version its own range (or dist-tag) allows, per
dependent, so `bun.lock` no longer stays stale after an update;
overrides/catalogs changed since the last install are honored. From a
workspace member or `--filter`, only what the selected workspaces reach
is re-resolved; from the root, everything.
- `bun update <name>` reaches any depth, matches `npm:` aliases by real
name, updates in place, never adds to `package.json`, and errors on a
name nothing selected depends on. `-r`/`--filter` fan a named update out
across workspaces. `--latest` never downgrades a locked version that is
ahead of the tag, and `update <name> --latest` also refreshes that
package's own dependencies.
- Plain updates keep dist-tag literals and non-caret ranges (`*`, `1.x`,
`^1 || ^2`) exactly as written and only move the lockfile; `--latest`
rewrites them to the resolved version as before. `bun update -i` applies
only what you selected. New: positional patterns (`bun update
'@types/*'`), `--dev`/`--prod`/`--no-optional`, `-L`, `bun up`.
- `package.json` is written after resolution and `bun.lock`'s declared
ranges, overrides and catalogs are re-derived from the final
`package.json`, replacing the per-command literal rewriting; a no-op
update leaves the file byte-identical.

#### Overrides

- **Nested overrides** (#6608): npm's nested objects, yarn's `a/b` paths
and pnpm's `a>b` selectors, applied to the direct parent→child edge;
**version-scoped targets** (`"lodash@<4.17.21": "4.17.21"`, the shape
`pnpm audit --fix` writes), matched against the dependent's declared
range as pnpm does. Rules persist inside the `overrides` section and the
file is stamped `lockfileVersion: 3` **only when such rules exist** —
existing lockfiles are byte-identical. Flat overrides additionally fix
`$ref` to workspace-member deps, catalog-valued rules going stale, and
warn on pnpm's `-` / `pkg@` forms.

#### Workspaces and filters

- `bun add|remove|update … --filter <ws>` (also `-F`, also `bun install
<pkg> --filter`) edits the selected workspaces' `package.json` files and
**links only those workspaces**, like `bun install --filter`. Filters
gain pnpm's relation selectors (`foo...`, `...foo`, `foo^...`,
`...^foo`) and `{dir}` subtrees, for the install family **and** `bun run
--filter`; `--filter` may precede the subcommand; every command warns
about patterns that match nothing; `add`/`remove` no longer select the
root implicitly.
- `bun add <pkg> --catalog[=name]` reuses an existing catalog entry,
keeps a range an explicit version fits, catalogs the range a package
already declares, decides per target, and refuses workspace names and
local paths; a plain `bun add` uses a default-catalog entry when one
exists. A package defined in both `catalog` and `catalogs.default` is an
error. `catalog:` peers of registry packages bind to the importer's copy
instead of the root catalog.
- `--frozen-lockfile` / `bun ci` on turbo-pruned monorepos: pruned-away
workspaces are tolerated, a survivor depending on a pruned workspace is
an error, catalog subsets are accepted, and an overrides/catalogs change
is a frozen failure.

#### One output vocabulary

Every command here prints the install family's shapes: header, glyph
rows (`+`/`-`/`↑`, dedupe's `↳ name old → new`), exactly one noun-first
summary line with counts and a duration (`2 duplicate versions removed,
3 packages installed (checked 5 packages) [12ms]`, `N packages removed
(checked C) [t]`), no-ops that say what was checked, remedies printed as
copy-pasteable command lines, warnings as `warn:`, `--silent` printing
nothing, and errors with their remedy together on stderr. Transitive and
named updates render as the summary's `↑` rows (once per package;
`--dry-run` prints the same rows plus `N packages would be updated`);
dedupe reports after the install it triggers, so lifecycle-script output
never splits it. A lockfile whose bytes did not change is no longer
rewritten (`Saved lockfile` only prints on a real write;
`--lockfile-only` no-ops print `Done! Checked N packages (no changes)`).
This came out of running every command against fixtures and comparing
with `install`/`add`/`remove` (95 findings, all fixed).

#### Config precedence

A project's `bunfig.toml` now beats any `.npmrc` (project or user-level)
for the same key (npmrc files → bunfig's set fields → CLI); npmrc-only
settings such as `//host/:_authToken` still attach to bunfig-declared
registries, matched by host and path regardless of how either file
spells the trailing slash.

#### Lockfile migration

- `package-lock.json`: rebuilt around a reachability walk that derives
each resolution from the entry itself. Fixes `git+https://github.com/…`
resolutions being written unparseably (the next install threw the
lockfile away), root `bundleDependencies` migrating to an **empty**
lockfile, lockfileVersion 1 (and npm's upcoming 4) making `bun install`
exit 1 instead of resolving fresh, dependency-level bundles,
`dependencies`+`optionalDependencies` double edges, unreferenced entries
aborting the migration, duplicate packages for identical `name@version`
at nested paths, lost `optionalPeers`, lost integrity when a bundled
copy was seen first, and `overrides` not being carried over. All 57 of
arborist's v2/v3 fixture projects are vendored and migrated under
snapshot.
- `pnpm-lock.yaml` v9: bare-hash `patchedDependencies`, snapshot
aliases, `catalog:default`, recorded tarball URLs, git `path:`,
multi-document files, `runtime:` entries, named registries,
peer-suffixed keys chosen per importer, injected workspaces,
manifest-only importer deps.

#### Isolated linker

- An existing store entry whose dependencies re-resolved (override,
dedupe, update) now has its links refreshed on the next install
(measured cost below). `bun prune` builds the same store the installer
builds, so stale `name@version+<peerhash>` variants left by peer bumps
are removed and a kept package's real entry never is (whether it was
installed with full or `--production` features); on the hoisted linker,
dedupe / audit fix / update delete the nested copies whose rows they
collapsed instead of leaving the old copy loadable.
- Blocked entries resume through per-entry intrusive waiter lists
instead of a scan of every store entry after each completion (robobun's
#25983/#28425 attempted this). Measured on the reporter's repro from
#25799 (2,259 store entries) and a synthetic 6,425-entry monorepo, PR
build vs merge-base build: main-thread CPU in the link phase drops 0.85
→ 0.33 s and 5.5 → 0.85 s (the removed work grows quadratically); wall
time is unchanged with spare cores and 16% / 26% faster pinned to one
CPU, the CI/Docker shape in those reports. (The minute-long installs
originally reported were peer resolution, fixed before this PR's base.)
- Two pre-existing leaks surfaced by the new LSan-enabled tests are
fixed: the header buffer of every authenticated registry request, and
the per-entry lifecycle-script lists.

#### Other bug fixes

`bun add x@npm:pkg` writes a range; `bun add --trust a b` no longer
drops `b` when `a` was already trusted; `bun add x --dev` no longer
rewrote every group in `bun.lock`; `catalog:` peer hoisting;
`dependency::Version::eql` treated all `catalog:` specifiers as equal; a
`file:` package whose dependencies reach itself (its own name, an `npm:`
alias under its own name, two link targets depending on each other — the
shapes a `package-lock.json` migration produces, and #25202's
`workspace:.` self-reference) hung `bun install` forever in the hoisting
tree — the migration shapes now install, and #25202's literal shape now
terminates with `Workspace dependency "foo" not found` rather than
installing as npm does; a peer of a `file:` package that was only placed
nested was also written to `optionalPeers` in a migrated bun.lock, so
the next `--frozen-lockfile` failed and a plain install rewrote the
lockfile; `catalog:` literals in `bun update`; alias output in the
install summary; help/completions for everything above.

#### Behavior changes to note in the release notes

- `bun update` moves transitive packages; `bun update <name>` no longer
adds an undeclared package (exit 1); `--production`/`--prod` on update
means "only update `dependencies` and `optionalDependencies`" (a group
filter like `--dev`, not the install flag) and `-r`+names with no match
is an error; `-i` updates only the selection.
- Project `bunfig.toml` overrides any `.npmrc`.
- `bun install <pkg> --filter x` edits `x` (not the root); `bun add y
--filter x` no longer installs a package named `x`; `add`/`remove
--filter '*'` no longer includes the root.
- A plain `bun add x` in a workspace whose default catalog lists `x`
writes `catalog:`; `audit fix` may rewrite exact pins;
`--frozen-lockfile --lockfile-only` writes nothing; overrides/catalog
changes fail frozen installs.
- One-time lockfile churn after upgrading for projects with `catalog:`
peers or dead `pkg@range` override rows; lockfiles that use
nested/scoped overrides are v3 and unreadable by older Bun (only when
opted in). Turborepo, Nx and Dependabot have been checked; the needed
upstream changes are open (nrwl/nx#36666 covers v2 and v3;
vercel/turborepo#13740 accepts v3 and preserves the object rows through
prune — turborepo main today parses v2 and rejects v3; dependabot needs
nothing). Note v2 itself only exists on the 1.4 line.
- `bun audit --json` keeps npm's contract: `--audit-level`/`--ignore`
decide the exit code, the JSON document is the full registry report
(closes #31013 as won't-change). Automatic removal of stale
`node_modules` entries on plain `bun install` (#32974) is separate from
this PR: `bun prune` is the manual form, and dedupe / audit fix / update
now clean up the nested copies they collapse on the hoisted linker;
#32974 should reuse prune's planner, and #29512 (sbom) is sequenced
after this so it can build on `reachable.rs` instead of carrying its own
walk.
- Deliberately kept where we differ from pnpm: root `bun update` covers
the whole workspace; dependents whose ranges allow follow a moved
version (one copy, not two); `--latest` works on transitive names;
`--no-save` touches neither file; prune deletion failures exit 1; audit
requests stay per-registry.

#### Performance

Measured on a 1,113-package Next/Prisma/MUI app (PR build vs a PR build
of the merge base, interleaved, plus canary and 1.3.14): every hoisted
cell is within noise except no-op install, +0.8 ms (+2%, identical
syscalls); isolated no-op is +1.9 ms (+3.9%) — the deliberate cost of
re-checking existing entries' links every install rather than persisting
a stamp file. Everything added is otherwise off the plain-install path
(gated on the feature being used or on a diff), and id-indexed sets are
bitsets.

### How did you verify your code works?

~1,100 new or ported test cases across the install suites (designed
behavior, cases ported from pnpm's suites, pinning tests for pnpm bugs
this implementation is immune to, arborist's fixtures, and the CI review
findings), all `toStrictEqual`; the whole `test/cli/install` directory
passes locally and the existing suites are unchanged except where a
pre-existing expectation was deliberately changed (each listed above).
`bun update` was additionally verified with a rerunnable differential
harness that runs pnpm 11 and this branch on 26 scenario families
against one registry and diffs the resulting resolutions edge by edge —
after this PR only the deliberate differences above remain. Ecosystem:
Turborepo, Nx and Dependabot were checked against the new lockfile
output.

Co-authored work absorbed with credit: @kjanat's #38190 (alias handling,
co-author on the commit), @charpeni's #31143 and @crystalin's #34407
(both superseded), and the tests of the earlier `bun update` PRs (#31752
by @zlotnika, #33127, #36381, #36729, #38224). robobun's #34688
(folder-dependency cycles; its tests are lifted, co-author on the
commit) and #37289 (migrated optionalPeers; its test is lifted,
co-author on the commit) were fixed independently here and are closed by
this PR. #28422's quadratic scan is fixed here as well (already closed).

Related but not closed — `bun prune` gives these a manual fix while the
automatic-cleanup asks stay open: #8662, #26305, #29793, #21216, #16176.
Also related: #10930, #26970, #26751.

Fixes #1343
Fixes #3605
Fixes #14719
Fixes #24122
Fixes #18612
Fixes #20238
Fixes #25826
Fixes #23615
Fixes #26973
Fixes #20593
Closes #31013
Fixes #28959
Fixes #28402
Fixes #27897
Fixes #26675
Fixes #10949
Fixes #18504
Fixes #13388
Fixes #24523
Fixes #6608
Fixes #19059
Fixes #16569
Fixes #8262
Fixes #11901
Fixes #13469
Fixes #25202
Closes #29664
Closes #31143
Closes #34407
Closes #34688
Closes #37289
Closes #38190

---------

Co-authored-by: Kaj Kowalski <info@kajkowalski.nl>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: robobun <117481402+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.

bun update only partially updates the lockfile

2 participants