Skip to content

install: re-sync self-referencing overrides after bun update - #31754

Closed
robobun wants to merge 2 commits into
mainfrom
farm/63053823/fix-self-ref-override-update
Closed

robobun wants to merge 2 commits into
mainfrom
farm/63053823/fix-self-ref-override-update

Conversation

@robobun

@robobun robobun commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes #31748

What

bun update writes a bun.lock that fails the very next bun install --frozen-lockfile when the project has a $name self-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 on bun install --frozen-lockfile.

Reproduce

mkdir repro && cd repro
cat > package.json <<'PKG'
{
  "name": "repro",
  "dependencies": { "is-number": "1.1.0", "is-odd": "0.1.0" },
  "overrides": { "is-number": "$is-number" }
}
PKG
bun install --save-text-lockfile            # locks is-number@1.1.0, override "1.1.0"
# widen the direct range so update has somewhere to go
# (is-odd@0.1.0 has a transitive is-number: ^1.1.0)
#   -> "is-number": "^1.1.0"
bun install --frozen-lockfile               # passes — consistent

bun update                                  # is-number 1.1.0 -> 1.1.2, "Saved lockfile"
bun install --frozen-lockfile               # ❌ "lockfile had changes, but lockfile is frozen"

(The issue reported this with nuxt -> vite-node -> vite; is-odd -> is-number is the same shape with immutable packages.)

Cause

A $name override stores a clone of the direct dependency's Dependency captured when package.json is parsed. bun update resolves a newer version and rewrites package.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.lock then no longer matches the package.json it was written next to. On the next install, Diff::generate re-parses $is-number against the bumped package.json (-> ^1.1.2), sees it differ from the lockfile's ^1.1.0, and — because the override drives how the transitive is-odd -> is-number resolves — the resulting dependency tree differs, so the frozen check fails. A plain bun install afterwards reconciles it, which is the existing workaround.

Fix

  • OverrideMap now tracks which entries came from a $name self-reference (override key name hash -> referenced dependency name hash), carried through the lockfile clean clone.
  • After bun update bumps a direct dependency, Lockfile::refresh_self_referential_overrides rewrites 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 what PackageJSONEditor writes into package.json.
  • Only overrides whose referenced dependency is actually being updated are touched, so literal overrides ("vite": "7.3.2") and dependencies bun update leaves alone are never rewritten.

Verification

New regression test in test/cli/install/overrides.test.ts:

  • without the fix: the saved lockfile keeps "overrides": { "is-number": "^1.1.0" } and the test fails (this is the bug).
  • with the fix: the override becomes ^1.1.2, matching package.json, and bun install --frozen-lockfile passes.

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 that test/cli/install/overrides.test.ts and test/cli/install/bun-update.test.ts pass.

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

robobun commented Jun 3, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:19 PM PT - Jun 2nd, 2026

❌ @autofix-ci[bot], your commit a1699f1 has 2 failures in Build #60108 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31754

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

bun-31754 --bun

@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. bun update only partially updates the lockfile #13388 - bun update only partially updates the lockfile — self-referential overrides retaining stale version ranges is one cause of incomplete lockfile updates
  2. (Regression) bun install --frozen-lockfile keeps reporting issues #19088 - bun install --frozen-lockfile keeps reporting "lockfile had changes" — stale self-referential overrides are one trigger for this exact error

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

Fixes #13388
Fixes #19088

🤖 Generated with Claude Code

@robobun

robobun commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, but I'll leave those off — this change only covers the self-referencing-override case:

  • bun update only partially updates the lockfile #13388 (bun update only partially updates the lockfile) is broader. Even with this fix, a plain bun install right after bun update still re-saves the lockfile once to reconcile the embedded workspace package.json snapshot and an orphan transitive entry (cosmetic; it does not break --frozen-lockfile). So this doesn't fully resolve it.
  • (Regression) bun install --frozen-lockfile keeps reporting issues #19088 is a different root cause — --frozen-lockfile in a workspace monorepo with architecture-specific optional deps (@swc/core, darwin/arm64 vs linux/x64), unrelated to overrides.

Keeping the PR scoped to #31748.

@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Re-sync $pkg overrides after bun update (no args) #31752 - Also fixes bun update writes a bun.lock that fails the very next bun install --frozen-lockfile #31748 by re-syncing self-referencing $name overrides after bun update; modifies the same OverrideMap/lockfile code

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 3, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c48fb487-821e-49b3-ae61-7f434a3c8047

📥 Commits

Reviewing files that changed from the base of the PR and between dae8b07 and a1699f1.

📒 Files selected for processing (1)
  • src/install/lockfile.rs

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.


Walkthrough

This PR ensures bun update refreshes self-referential overrides (overrides using $name) by tracking those references at parse time and re-synchronizing their override literals to the updated direct dependency versions before finishing the update.

Changes

Self-Referential Override Refresh During bun update

Layer / File(s) Summary
Self-referential override data model
src/install/lockfile/OverrideMap.rs
OverrideMap adds self_referential field to map override names to referenced dependency names. ParsedOverride wraps Dependency plus optional self_referential_name_hash. parse_override_value return type changes from Option<Dependency> to Option<ParsedOverride>. OverrideMap::clone copies the new field.
Parse and capture self-referential overrides
src/install/lockfile/OverrideMap.rs
parse_override_value detects $name references and sets self_referential_name_hash to the matched dependency's name hash; literal specs get None. Both overrides and resolutions parsing consume ParsedOverride, store the dependency in the main map, and populate self_referential when present.
Refresh stale overrides and integrate into update
src/install/lockfile.rs, src/install/PackageManager/install_with_manager.rs, test/cli/install/overrides.test.ts
Lockfile::refresh_self_referential_overrides finds self-referential overrides whose referenced direct deps are being rewritten, computes new override literals (preserving pin style ^/~/exact), batches differing rewrites, applies them (and reparses overrides.map), and install_with_manager calls this during the Update subcommand. A regression test verifies bun update leaves a lockfile that passes bun install --frozen-lockfile.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: re-syncing self-referencing overrides after bun update, which is the core fix addressed in this PR.
Description check ✅ Passed The description provides comprehensive context including problem statement, root cause analysis, implementation details, and verification approach. It directly addresses both template sections (What and How verified).
Linked Issues check ✅ Passed The PR fully addresses the requirements from issue #31748 by implementing the necessary tracking of self-referential overrides and refreshing them after bun update to maintain lockfile consistency.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the self-referential override synchronization issue: tracking in OverrideMap, refresh logic in Lockfile, conditional invocation in install_with_manager, and a targeted regression test.

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


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

@robobun

robobun commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as a duplicate of #31752, which fixes the same issue (#31748) with the same approach and was opened first (by the issue reporter, @zlotnika). I left a note there about one small refinement from this PR's diff. Deferring to #31752.

@robobun robobun closed this Jun 3, 2026
Comment thread src/install/lockfile.rs
Comment on lines +980 to +990

// 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),
};

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.

🔴 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:

  1. The override stores a clone of the direct dep with literal ^1.1.0.
  2. bun update resolves is-number@1.1.2; is-number is in updating_packages.
  3. refresh_self_referential_overrides: existing_literal = "^1.1.0" → which_version_is_pinned → Major → lockfile override = ^1.1.2.
  4. PackageJSONEditor::edit_update_no_args sees exact_versions = true → writes "is-number": "1.1.2" (bare) into package.json.
  5. Next bun install --frozen-lockfile: $is-number re-parses against package.json as exact 1.1.2; Diff::generate compares it to the lockfile's ^1.1.2 via Dependency::eql → Version::eql for Tag::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:

  1. Override clone's literal is npm:@preact/compat@^17.0.0.
  2. refresh_self_referential_overrides: which_version_is_pinned("npm:@preact/compat@^17.0.0") sees first non-whitespace char 'n', returns Major → lockfile override = ^17.0.5 (alias prefix gone). dependency::parse then parses this as plain react@^17.0.5, not @preact/compat.
  3. PackageJSONEditor writes "react": "npm:@preact/compat@^17.0.5" into package.json.
  4. 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_literal starts with npm:), split at last_index_of_char(existing_literal, '@'), run which_version_is_pinned on the suffix, and re-prepend existing_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.

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 writes a bun.lock that fails the very next bun install --frozen-lockfile

1 participant