Repository navigation
Conversation
bun update resolves a newer version for a direct dependency and rewrites
its package.json range (e.g. ^7.3.3 -> ^7.3.5) after resolution, but left
a $name self-referencing override ("overrides": { "vite": "$vite" })
pointing at the pre-bump range. The saved bun.lock then no longer matched
the package.json it was written next to, so the immediate next
bun install --frozen-lockfile failed with "lockfile had changes, but
lockfile is frozen" — breaking CI bots that run bun update and commit the
result.
A $name override stores a clone of the direct dependency captured at parse
time. Track which override entries came from a $name reference, and after
bun update bumps a referenced dependency, rewrite the override's version
literal to that dependency's resolved version, preserving the override's
pin style (^/~/exact) so it matches the range bun update writes into
package.json. Only overrides whose referenced dependency is actually being
updated are touched, so literal overrides and untouched dependencies are
left alone.
|
Updated 9:19 PM PT - Jun 2nd, 2026
❌ @autofix-ci[bot], your commit a1699f1 has 2 failures in
🧪 To try this PR locally: bunx bun-pr 31754That installs a local version of the PR into your bun-31754 --bun |
|
Found 2 issues this PR may fix:
🤖 Generated with Claude Code |
|
Thanks, but I'll leave those off — this change only covers the self-referencing-override case:
Keeping the PR scoped to #31748. |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
Disabled knowledge base sources:
WalkthroughThis PR ensures ChangesSelf-Referential Override Refresh During bun update
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
|
|
||
| // Preserve the override's existing pin style (`^`/`~`/exact). | ||
| let existing_literal = override_dep.version.literal.slice(string_buf); | ||
| let pinned = Semver::Version::which_version_is_pinned(existing_literal); | ||
| let version_fmt = resolved_version.fmt(string_buf); | ||
| let mut new_literal: Vec<u8> = Vec::new(); | ||
| let _ = match pinned { | ||
| Semver::PinnedVersion::Patch => write!(&mut new_literal, "{}", version_fmt), | ||
| Semver::PinnedVersion::Minor => write!(&mut new_literal, "~{}", version_fmt), | ||
| Semver::PinnedVersion::Major => write!(&mut new_literal, "^{}", version_fmt), | ||
| }; |
There was a problem hiding this comment.
🔴 This re-sync is meant to produce the exact same literal PackageJSONEditor writes into package.json, but it only mirrors the basic ^/~/exact path and misses two cases the editor handles, so the saved lockfile can still diverge from package.json and the next bun install --frozen-lockfile still fails: (1) when exact_versions is on (--save-exact / bunfig exact = true), the editor short-circuits and writes the bare resolved version, but this still derives the prefix from the old literal; (2) for npm: aliases the editor splits at the last @ and re-prepends the alias prefix, but this passes the whole npm:@scope/bar@^1.0.0 to which_version_is_pinned (returns Major on leading n) and emits ^X.Y.Z — dropping the alias entirely. The re-sync should check manager.options.enable.exact_versions() and reuse the editor's alias-aware split/re-prepend so the two writers stay in lockstep.
Extended reasoning...
What the bug is
refresh_self_referential_overrides's docstring states its job is to rewrite each $name override "so the result matches the range PackageJSONEditor writes into package.json for the same dependency". But it only implements the editor's default which_version_is_pinned → ^/~/exact path. PackageJSONEditor has two additional behaviors that this code does not mirror, so for those inputs the lockfile override and the package.json range diverge again — exactly the symptom this PR is fixing.
Code paths
Case 1 — exact_versions (PackageJSONEditor.rs:443, :1151, :1229): when options.exact_versions is true (set from manager.options.enable.exact_versions() at updatePackageJSONAndInstall.rs:571/583, via --save-exact/--exact or bunfig [install] exact = true), the editor writes the bare resolved version with no prefix, before ever consulting which_version_is_pinned. refresh_self_referential_overrides has no such check — it always derives the prefix from which_version_is_pinned(existing_literal).
Case 2 — npm: aliases (PackageJSONEditor.rs:450-465, 489-512): for an aliased dep like "foo": "npm:@scope/bar@^1.0.0", the editor first strips to the substring after the last @ to determine pin style, then re-prepends npm:@scope/bar@ to the new version so the alias survives. refresh_self_referential_overrides reads override_dep.version.literal (the cloned direct dep's literal, i.e. npm:@scope/bar@^1.0.0) and feeds it straight into which_version_is_pinned, which is documented as "npm: is assumed already removed from aliased versions" (Version.rs:239) and whose first-char switch hits the catch-all _ => return PinnedVersion::Major on 'n'. The new literal becomes ^X.Y.Z with the alias prefix dropped.
Why nothing prevents it
Aliased deps reach this code: edit_update_no_args inserts them into manager.updating_packages (keyed by the alias name, with is_alias = true), their version.tag is Npm, and the resolution's tag is Npm, so every guard in the loop passes. exact_versions is a documented, commonly-used option (many projects set save-exact = true in .npmrc/bunfig) and bun update honors it.
Step-by-step proof
Case 1. dependencies: { "is-number": "^1.1.0" }, overrides: { "is-number": "$is-number" }, run bun update --save-exact:
- The override stores a clone of the direct dep with literal
^1.1.0. bun updateresolvesis-number@1.1.2;is-numberis inupdating_packages.refresh_self_referential_overrides:existing_literal = "^1.1.0"→which_version_is_pinned→Major→ lockfile override =^1.1.2.PackageJSONEditor::edit_update_no_argsseesexact_versions = true→ writes"is-number": "1.1.2"(bare) into package.json.- Next
bun install --frozen-lockfile:$is-numberre-parses against package.json as exact1.1.2;Diff::generatecompares it to the lockfile's^1.1.2viaDependency::eql→Version::eqlforTag::Npm(literals differ, ranges differ) →overrides_changed = true→has_diffs()→ "lockfile had changes, but lockfile is frozen".
Case 2. dependencies: { "react": "npm:@preact/compat@^17.0.0" }, overrides: { "react": "$react" }, run bun update:
- Override clone's literal is
npm:@preact/compat@^17.0.0. refresh_self_referential_overrides:which_version_is_pinned("npm:@preact/compat@^17.0.0")sees first non-whitespace char'n', returnsMajor→ lockfile override =^17.0.5(alias prefix gone).dependency::parsethen parses this as plainreact@^17.0.5, not@preact/compat.PackageJSONEditorwrites"react": "npm:@preact/compat@^17.0.5"into package.json.- Lockfile override (
^17.0.5) and package.json's$react(npm:@preact/compat@^17.0.5) disagree → frozen install fails. Worse, this is a regression vs. pre-PR: before, the saved override was merely stale but still aliased; now it points at the wrong package entirely.
Impact
Both cases reproduce the exact failure the PR is meant to fix (bun update writes a lockfile that immediately fails --frozen-lockfile) under realistic configurations: save-exact is widely used, and aliasing a package while also overriding transitives to it (react → @preact/compat is the canonical example) is a documented npm pattern.
Fix
Mirror PackageJSONEditor exactly:
- If
manager.options.enable.exact_versions(), emit the bare{version_fmt}and skip pin detection. - Otherwise, if the direct dep is an alias (or simply: if
existing_literalstarts withnpm:), split atlast_index_of_char(existing_literal, '@'), runwhich_version_is_pinnedon the suffix, and re-prependexisting_literal[..=at_index]to the result.
Ideally factor the editor's new_version computation into a shared helper so the two writers can't drift again.
Fixes #31748
What
bun updatewrites abun.lockthat fails the very nextbun install --frozen-lockfilewhen the project has a$nameself-referencing override ("overrides": { "vite": "$vite" }) over a dependency that has a transitive on the overridden package.This breaks weekly "update dependencies" CI bots: they run
bun update, commit the result, and CI immediately red-X's onbun install --frozen-lockfile.Reproduce
(The issue reported this with
nuxt -> vite-node -> vite;is-odd -> is-numberis the same shape with immutable packages.)Cause
A
$nameoverride stores a clone of the direct dependency'sDependencycaptured whenpackage.jsonis parsed.bun updateresolves a newer version and rewritespackage.json's range (^1.1.0->^1.1.2) after resolution, but nothing updates the stored override, which keeps the pre-bump^1.1.0.The saved
bun.lockthen no longer matches thepackage.jsonit was written next to. On the next install,Diff::generatere-parses$is-numberagainst the bumpedpackage.json(->^1.1.2), sees it differ from the lockfile's^1.1.0, and — because the override drives how the transitiveis-odd -> is-numberresolves — the resulting dependency tree differs, so the frozen check fails. A plainbun installafterwards reconciles it, which is the existing workaround.Fix
OverrideMapnow tracks which entries came from a$nameself-reference (override key name hash -> referenced dependency name hash), carried through the lockfilecleanclone.bun updatebumps a direct dependency,Lockfile::refresh_self_referential_overridesrewrites each self-referencing override whose referenced dependency is being updated to that dependency's resolved version, preserving the override's pin style (^/~/exact) so the value matches exactly whatPackageJSONEditorwrites intopackage.json."vite": "7.3.2") and dependenciesbun updateleaves alone are never rewritten.Verification
New regression test in
test/cli/install/overrides.test.ts:"overrides": { "is-number": "^1.1.0" }and the test fails (this is the bug).^1.1.2, matchingpackage.json, andbun install --frozen-lockfilepasses.Also verified manually that
bun update(no-args and with-args),bun add, literal (non-$) overrides, and projects without self-referencing overrides are unchanged, and thattest/cli/install/overrides.test.tsandtest/cli/install/bun-update.test.tspass.