Skip to content

install: load root and workspace dependencies from bun.lock the way package.json parses them - #38869

Closed
robobun wants to merge 1 commit into
mainfrom
farm/0bb5d6ef/bun-lock-workspace-dep-rows
Closed

robobun wants to merge 1 commit into
mainfrom
farm/0bb5d6ef/bun-lock-workspace-dep-rows

Conversation

@robobun

@robobun robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • In a monorepo where a workspace package lists a sibling workspace in two dependency groups (activepieces: packages/core/shared has "@activepieces/core-piece-types": "workspace:*" under both dependencies and devDependencies), every bun install reports the workspace as changed, with nothing changed on disk: bun install --verbose prints Workspace package "packages/core/shared" has added 0 dependencies, removed 0 dependencies, and updated 1 dependencies on every run, summary.update is non-zero, and the install takes the re-resolve path instead of the no-op path. Same when the root lists a workspace in two groups. Reproduces on 1.3.14, 1.4.0 and main.
  • Cause: bun.lock stores a workspace's dependencies as the literal specifiers, and the loader rewrites each entry that resolved to a workspace package into the form Package::parse_dependency produces (map_dep_to_pkg, src/install/lockfile/bun.lock.rs), but a seen_deps check skipped the rewrite for the second and later entries of a name (root loop and workspace loop, formerly around lines 3102 and 3187). The parser rewrites every entry, so the two Dependency lists never compare equal in Diff::generate_inner (src/install/lockfile/Package.rs, the Dependency::eql check) and summary.update is bumped once per extra entry, forever.
  • The check cannot simply be dropped: the rewrite itself is broader than the parser's rule. The parser links an entry only for a workspace: specifier or a range the workspace's version satisfies; the loader rewrote anything bound to a workspace. Dropping the check alone makes the existing test matching workspace devDependency and npm peerDependency (a workspace:* devDependency plus an unsatisfied 2.0.0 peer on the same workspace) report an update on every install instead, because the peer entry then gets rewritten too. The same over-broad rewrite already makes these single-entry shapes report an update on every install on main: an unsatisfied peer range on a workspace (which also re-resolves the peer against the registry on every install), a range on a versionless workspace (install: stop re-saving an unchanged lockfile for wildcard deps on versionless workspace members #37249's shape), a catalog: entry pointing at a workspace, and a dist-tag the registry does not have (which the resolver answers with the workspace).

Fix

  • Root and workspace entries go through map_manifest_dep_to_pkg, which binds the entry and rewrites it only when WorkspaceLinks::links says the parser would: workspace: specifiers always; npm ranges when they name the bound workspace package (the name inside the range is compared against the package's name, not only the hashes the version map and peer bindings are keyed by) and the version bun.lock recorded for it satisfies them; nothing else. Every entry is classified on its own, so a name listed in several groups gets each entry rewritten or left alone exactly as the parser does. seen_deps is gone.
  • Why this is the right rule: Diff::generate compares the loaded list against a fresh parse of the same package.json, so the loader has to reproduce the parser's output, and the parser's rule is the one in Package::parse_dependency (src/install/lockfile/Package.rs, the Tag::Npm arm linking a satisfied range and the Tag::Workspace arm). Resolutions are untouched: every entry still binds to the same package as before, only the in-memory version differs, and that in-memory state is now the one a fresh install has too (the fresh resolver binds unsatisfied peers and versionless wildcards to the workspace without retagging the dependency). The saved bun.lock is byte-identical before and after, so existing lockfiles load as they are.
  • Ranges are checked against the version recorded in bun.lock, not the one on disk, so a workspace bumped out of an entry's range still loads as linked, diffs against the parse, and gets re-resolved (tested).
  • linkWorkspacePackages = false: the parser then links no range, so a range bun.lock still binds to a workspace, written out or behind a catalog: entry, was linked while the setting was on. Those keep loading as linked, which is the previous behavior and is what makes them diff once and re-resolve against the registry (tested for both forms, including that the next install is quiet again). Everything a fresh install binds to the workspace under either setting loads as parsed: peers (bound to the workspace of their name by the hoister), entries an override redirects (an npm: alias is never overridden, the same exemption the resolver applies), catalog: entries holding workspace:, and dist-tags the registry does not have; marking any of those would be the same permanent update, and was, on main (tested for each). This is the rule install: fail --frozen-lockfile on manifest drift and fix the spurious lockfile re-saves behind it #33632's loader commit arrives at as well, so that PR can rebase its loader half onto this.
  • Entries of installed (non-workspace) packages keep the unconditional rewrite in map_dep_to_pkg; nothing diffs them, and this change does not touch their behavior.
  • Tests, all in test/cli/install/bun-workspaces.test.ts under dependencies on a workspace load from bun.lock the way package.json parses them: a table of 19 shapes (the two-group shapes from the report in every group combination, satisfied ranges plain and as an npm: alias, unsatisfied ranges, versionless wildcard, catalog entry, missing dist-tag, an override sending a range to the workspace, and six of those with linking off) asserting the loaded entries via the lockfile dump and that a second --verbose install does not report the workspace (the dump loads with the test process's default settings, so for the linking-off rows the --verbose install in the fixture is the assertion that exercises that setting); a root variant; the bump test; and the linking-off re-resolution test for a plain and for a catalog range, each also checking the third install is quiet. Against main's bun.lock.rs 19 of the 23 fail (the dump shows workspace * for the second entry, or workspace packages/pkg2 for the peer/wildcard/catalog/dist-tag/override entries); the 4 that pass on main pin behavior that must not change (the dev+unsatisfied-peer shape with linking on and off, the bump, the plain-range re-resolution after turning linking off). With the override exemption removed, the linking-off override row fails on the --verbose line, with the re-resolve binding the workspace again. All 23 pass with the fix.
  • The 6 changed snapshots in dependency on workspace without version in package.json show the only in-memory change for that test: wildcard and dist-tag entries on a versionless workspace keep their parsed tag, package_id unchanged. (The "tag": "no-deps" value in the new dist-tag snapshot is a pre-existing bug in the debug dump, already certified by three other snapshots in that file; install: fail --frozen-lockfile on manifest drift and fix the spurious lockfile re-saves behind it #33632's last commit fixes it.)
  • Also green locally: bun-workspaces, bun-lock, catalogs, lockfile-only, lockfile-version-2, frozen-lockfile-missing-workspace, frozen-lockfile-pruned, bad-workspace, bun-add-catalog, isolated-install, isolated-relink, bun-lockb, migrate-bun-lockb-v2, bun-update-lockfile-sync, bun-install-registry, migration/migrate, pnpm-migration, yarn-lock-migration, source lints; cargo clippy -p bun_install clean. migration/complex-workspace needs gitlab/bitbucket access and fails here with or without the change.
  • Overlapping open PRs, so the reviewer can sequence them: install: fail --frozen-lockfile on manifest drift and fix the spurious lockfile re-saves behind it #33632 (large, frozen-lockfile manifest checks) contains this same loader change as part of its first commit (same seen_deps removal, a map_manifest_dep_to_pkg with the same rule, the same 6 snapshot hunks) plus unrelated pieces; this PR is that loader slice on its own, with the name check and the catalog handling added in review, and install: fail --frozen-lockfile on manifest drift and fix the spurious lockfile re-saves behind it #33632 would rebase down to its remaining pieces. install: stop re-saving an unchanged lockfile for wildcard deps on versionless workspace members #37249 gates only npm entries and keeps seen_deps, so it fixes the versionless-wildcard shape but not the two-group ones; this supersedes it (noted there). install: link wildcard deps to prerelease workspace members in any position #37264 is changing the parser's range rule (a wildcard accepting a prerelease member) through a shared predicate; WorkspaceLinks::links mirrors the rule as it is on main today, and whichever of the two lands second points the loader at that predicate (noted there). The merged install: collapse a workspace's same-name dependency slots into one entry so --frozen-lockfile is stable #36303 is about hoisting two entries with different versions and does not interact with this.
Earlier revision

The first push exempted only peers from the linking-off fallback, so with linking off a catalog: entry holding workspace: or a missing dist-tag was still marked and diffed forever (as on main). Review caught it; the fallback now applies to ranges only, with the catalog and dist-tag shapes in the table and a catalog-range variant of the re-resolution test. A second round asked for the workspace's name to be compared rather than trusting the hash-keyed lookups alone; that is the name check in links and the aliased-range row. A third pass compared the rule with #33632's and took its one extra exemption (overridden entries with linking off), with the two override rows; the branch was also squashed and rebased onto current main at that point.

Background

  • Dependency entry: each line of a package.json dependency group becomes one Dependency in lockfile.buffers.dependencies (name, behavior = which group, parsed version); a package owns a contiguous range of them, and buffers.resolutions maps each one to a package id. The same name in two groups is two entries with different behaviors.
  • Workspace dependency: a Dependency whose version tag is workspace and whose value is the workspace's path. The parser produces one for a workspace: specifier, and, while linkWorkspacePackages is on, for an npm range satisfied by the workspace's version (the literal is kept either way, which is what bun.lock prints). Dependency::eql compares tag and value, so a workspace:* entry left with the value * is not equal to one carrying the path.
  • bun.lock loading (parse_into_binary_lockfile): parses every literal back into a Dependency, resolves the root's and each workspace's entries by name against the packages map, then the installed packages' entries by tree path. The rewrite discussed here happens at that binding step.
  • Diff::generate (Package.rs): on every install, compares the loaded root with a fresh parse of package.json and recurses into each workspace; any entry that is not eql counts as an update, which prints the verbose line above, makes had_any_diffs true and re-resolves the workspace. bun install --frozen-lockfile does not consult these counts, so this shape never failed CI; it only did the extra work and (for unsatisfied peers) the extra registry round trip on every install.
  • The lockfile dump used by the tests (install_test_helpers.parseLockfile) loads bun.lock through this same loader and prints each entry keyed by its version tag, which is how the tests observe the loaded form directly.
Repro (released bun)
mkdir -p repro/packages/{a,b} && cd repro
echo '{"name":"root","private":true,"workspaces":["packages/*"]}' > package.json
echo '{"name":"a","version":"1.0.0"}' > packages/a/package.json
echo '{"name":"b","version":"1.0.0","dependencies":{"a":"workspace:*"},"devDependencies":{"a":"workspace:*"}}' > packages/b/package.json
bun install
bun install --verbose 2>&1 | grep "Workspace package"
# Workspace package "packages/b" has added 0 dependencies, removed 0 dependencies, and updated 1 dependencies

Listing a in three groups prints updated 2. The same line appears for dependencies + peerDependencies, dependencies + optionalDependencies, ^1.0.0 in two groups, a lone peerDependencies: {"a": "2.0.0"}, dependencies: {"a": "*"} on a versionless a, and dependencies: {"a": "catalog:"} with catalog: {"a": "workspace:*"}; a single workspace:*, ^1.0.0 or workspace:* + 2.0.0 peer entry is quiet. On 1.3.x/1.4.0 the second install also prints Saved lockfile; main since #38333 skips the write when the bytes are unchanged, so there the symptom is the verbose line and the re-resolve.

@coderabbitai

coderabbitai Bot commented Aug 15, 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: 11 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: 5e33d9ed-d099-42c9-abc7-dbf1f8c5b689

📥 Commits

Reviewing files that changed from the base of the PR and between 732491c and 0097912.

⛔ Files ignored due to path filters (1)
  • test/cli/install/__snapshots__/bun-workspaces.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (2)
  • src/install/lockfile/bun.lock.rs
  • test/cli/install/bun-workspaces.test.ts

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

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: reproduced with the fixture in the PR description (released 1.4.0 and a main build): a workspace listing a sibling in two dependency groups makes every bun install --verbose print Workspace package "packages/b" has added 0 dependencies, removed 0 dependencies, and updated 1 dependencies; the lockfile dump shows the second entry loaded with the value * instead of the workspace path. Fix and tests are in this PR (one commit, rebased onto current main); 19 of the 23 new tests fail against main's bun.lock.rs and all pass with the change.

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:38 AM PT - Aug 15th, 2026

@robobun, your commit 4eb1d80 is building: #97829

Comment thread src/install/lockfile/bun.lock.rs Resolved
Comment thread src/install/lockfile/bun.lock.rs Resolved
Comment thread src/install/lockfile/bun.lock.rs Resolved
Comment thread src/install/lockfile/bun.lock.rs Resolved
Comment thread src/install/lockfile/bun.lock.rs Resolved
Comment thread src/install/lockfile/bun.lock.rs Resolved
Comment thread src/install/lockfile/bun.lock.rs Resolved
Comment thread src/install/lockfile/bun.lock.rs Resolved
Comment thread src/install/lockfile/bun.lock.rs Resolved
Comment thread src/install/lockfile/bun.lock.rs Resolved
Comment thread src/install/lockfile/bun.lock.rs Resolved
Comment thread src/install/lockfile/bun.lock.rs Resolved
Comment thread src/install/lockfile/bun.lock.rs Resolved
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Review follow-ups:

  • The linking-off arm did catch catalog: and dist-tag entries, so a catalog: entry holding workspace:* (or a dist-tag the registry does not have) would still have diffed forever with linkWorkspacePackages = false, as on main. c5a8052 narrows that arm to ranges (plain, or the range behind a catalog entry); both shapes are in the test table with linking on and off, and the re-resolution test now also covers a catalog range. 16 of the 20 tests fail against main's loader, all pass here.
  • 9c618ad and 7f0a983 trim the comments down to the one doc comment on the predicate.

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

The linking-off arm now goes through catalogs.resolve_range(...).tag == Npm, so catalog: → workspace:* and dist-tags fall through untagged with linkWorkspacePackages off — that closes the case I raised earlier, and the added test rows cover both shapes. I didn't find further issues; a human look is still worthwhile since this rewrites the workspace-link rule in the bun.lock loader (mirroring Package::parse_dependency) and changes six standing snapshots in dependency on workspace without version in package.json.

Checked: the linking-on Npm arm matches the parser's link_workspace_packages && satisfies gate at src/install/lockfile/Package.rs:1886; resolve_range returns the dep's own version for non-catalog tags so DistTag/Workspace/Catalog-of-workspace correctly fall to false; link_dep_to_workspace preserves the whole-struct-assign so DependencyVersion::Drop still frees the prior npm chain; seen_deps removal has no other reader; the snapshot deltas keep package_id at the workspace and only change the version tag.

Extended reasoning...

Overview

The PR changes how parse_into_binary_lockfile (src/install/lockfile/bun.lock.rs) retags root and workspace dependencies that bun.lock bound to a workspace package. Previously it retagged every such entry to Workspace unconditionally (via map_dep_to_pkg), except that a seen_deps set skipped the retag for the second and later entries of a name — so a workspace listed in two dependency groups loaded with one entry retagged and one not, permanently diffing against the fresh parse. The fix replaces seen_deps and the unconditional retag with map_manifest_dep_to_pkg + WorkspaceLinks::links(), which retags only when Package::parse_dependency would: workspace: always; Npm ranges only when the recorded workspace version satisfies them and linkWorkspacePackages is on; with linking off, only non-peer entries whose (catalog-resolved) tag is Npm keep loading as linked so the diff re-resolves them once against the registry. Installed-package deps still go through the old map_dep_to_pkg. Six snapshots in dependency on workspace without version in package.json change (wildcard/dist-tag on a versionless workspace now keep their parsed tag; package_id is unchanged). Twenty new tests are added.

Prior review

My earlier run flagged that the linking-off fallback arm was a _ catch-all that also matched Catalog and DistTag, so a catalog: entry holding workspace:* would still permanently diff with linking off. c5a8052 replaced that with !is_peer() && catalogs.resolve_range(...).tag == Npm; I traced resolve_range (CatalogMap.rs:152) — for non-Catalog deps it returns &dep.version verbatim, so DistTag → DistTag → false, and for a catalog entry it returns the looked-up entry's version, so workspace:* → Workspace → false and ^1.0.0 → Npm → true. The new test table has both catalog shapes with linking on and off, plus a re-resolution test for a catalog range. The comment-cop feedback was addressed by trimming to a single doc comment on links().

Security risks

None. This is in-memory tag classification during lockfile load; resolutions (package_id) are unchanged, the saved bun.lock is byte-identical, and no untrusted input handling changes.

Level of scrutiny

High. This is the bun install lockfile loader — every workspace install goes through it, and the correctness argument depends on WorkspaceLinks::links staying in lockstep with Package::parse_dependency's linking rule in a separate file. I verified the linking-on Npm arm against Package.rs:1880-1904 and it matches (link_workspace_packages && satisfies), and the linking-off arm's convergence argument holds for the shapes covered. But this is exactly the kind of two-place-mirror that a maintainer who owns Package::parse_dependency should confirm, and the six snapshot changes deserve a human eye to confirm no downstream consumer of the loaded lockfile relies on those entries being tagged Workspace.

Other factors

Extensive test coverage (16-row table across group combinations, satisfied/unsatisfied ranges, versionless, catalog, dist-tag, ×linking on/off; plus root variant, bump, and linking-off re-resolution for plain and catalog ranges); the description states 16/20 fail against main's loader. link_dep_to_workspace is a straight extraction of the previous inline body, keeping the whole-struct assign for Drop. The description notes overlap with open #37249 (versionless-wildcard shape) which will need a rebase either way.

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

32-bit hash collisions can happen here so we should compare nmaes after hash passes

Comment thread src/install/lockfile/bun.lock.rs Resolved
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Done in 4eb1d80: WorkspaceLinks::links now compares the range's package name against the bound workspace package's name (String::eql on the bytes) before the workspace_versions lookup, and before the linking-off path loads a range as linked; a mismatch leaves the entry as parsed, which at worst diffs and re-resolves it. The name compared is the one inside the range (npm:no-deps@^1 compares no-deps), which is what the parser links on; the new a satisfied range aliased in dependencies and devDependencies row pins that.

For reference, the keys involved here are the 64-bit PackageNameHash (workspace_versions, package_index for peer bindings), not the truncated 32-bit ones, but the lookup no longer relies on either.

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

I re-reviewed after c5a8052/4eb1d80 and didn't find any bugs — the linking-off arm now resolves through catalogs.resolve_range and only retags when the underlying tag is Npm, so the catalog-holding-workspace:* and dist-tag cases I flagged earlier fall through to false (both now covered in the test table). Given this rewrites how root/workspace dependencies are retagged on load — a predicate that has to exactly mirror Package::parse_dependency across tag × behavior × linkWorkspacePackages — a human look from someone who owns src/install/lockfile would still be worthwhile.

What was reviewed:

  • WorkspaceLinks::links against the parser's rule: workspace: always; Npm ranges satisfied by the recorded workspace version; catalog/dist-tag never retagged with linking on; with linking off, only Npm ranges (direct or behind a catalog) load as linked so they diff once and re-resolve.
  • seen_deps removal: every entry is now classified independently, and resolutions[dep_id] is still written for every entry via map_manifest_dep_to_pkg.
  • The 6 snapshot changes: only the in-memory tag flips (npm/dist_tag instead of workspace); package_id unchanged.
  • The one open comment-cop lint on line 3504 is on a two-line comment explaining the name-hash-vs-name check — looks like a false positive.
Extended reasoning...

Overview

The PR changes how bun.lock's loader (parse_into_binary_lockfile in src/install/lockfile/bun.lock.rs) rewrites root and workspace dependency entries that resolved to a workspace package. Previously it unconditionally retagged the first entry per name to Workspace and skipped later ones via a seen_deps set; now every entry goes through a new map_manifest_dep_to_pkg that consults WorkspaceLinks::links to retag only when Package::parse_dependency would produce a Workspace tag for the same input. This makes the loaded dependency list byte-equal to a fresh parse, so Diff::generate stops reporting phantom updates on every install. 20 new tests in bun-workspaces.test.ts cover the shape matrix; 6 existing snapshots change (in-memory tag only, package_id unchanged).

Security risks

None identified. This is in-memory lockfile state reconstruction; no network, filesystem, or auth surface changes. The saved bun.lock bytes are unchanged.

Level of scrutiny

High. bun install lockfile loading runs on every install in every workspace monorepo, and the correctness of the no-op fast path depends on links() exactly mirroring the parser's linking rule across every combination of dependency tag, behavior flags, and the linkWorkspacePackages setting. A mismatch in either direction produces either the original bug (permanent phantom updates) or a stale resolution that never re-resolves. The predicate reads correctly to me and the test table is thorough, but the interaction with catalogs.resolve_range, peer-binding-by-name-hash (the reason for the explicit name check at line 3505), and the linking-off fallback are subtle enough that a maintainer who knows Package::parse_dependency should confirm the rule is complete.

Other factors

  • My earlier inline finding (the _ if !self.link_workspace_packages arm catching Catalog/DistTag) was addressed in c5a8052 by resolving through catalogs.resolve_range and gating on tag == Npm; the two shapes it would have broken are now in the test table with linking on and off, plus a catalog-range re-resolution test.
  • link_workspace_packages defaults to true when manager is None (is_none_or), matching the option's default — checked that the debug-dump path (install_test_helpers.parseLockfile, no manager) sees linking-on behavior, which is what the tests assert.
  • The removed seen_deps allocation and ? on get_or_put are gone; map_manifest_dep_to_pkg still writes resolutions[dep_id] unconditionally, so no entry loses its binding.
  • The PR notes overlap with #37249 (versionless-wildcard shape) — one will need a rebase; not a blocker for this review.
  • One unresolved comment-cop lint remains on the two-line name-hash comment at line 3504; the author already defended the (longer) doc comment on links() and trimmed the rest in 7f0a983.

…ackage.json parses them

The bun.lock loader marked every root or workspace dependency that resolved
to a workspace package as a workspace dependency, except the second and later
entries of a name listed in more than one dependency group, which it left as
parsed. Package.parse marks an entry as a workspace dependency exactly when
its specifier links the workspace (workspace:, or a range the workspace's
version satisfies while linkWorkspacePackages is on), and marks every such
entry. Wherever the two disagreed, Diff.generate counted an update on every
install of an unchanged repo and the install re-resolved the workspace each
time: a workspace listed in two dependency groups, an unsatisfied peer range
on a workspace, a range on a versionless workspace, a catalog entry pointing
at a workspace, a dist-tag the registry does not have, an override sending a
range to a workspace.

Root and workspace entries now follow the parser's rule, one entry at a time.
A range links when it names the bound workspace package (compared by name,
not only by the hash the version map and peer bindings are keyed by) and the
version recorded in the lockfile satisfies it, so a workspace that moved out
of range still diffs. With linkWorkspacePackages off the parser links
nothing, so ranges (plain or behind a catalog entry) that the lockfile still
binds to a workspace keep loading as linked, as before, and the diff
re-resolves them; peers, overridden entries, catalog entries holding
workspace: and dist-tags are bound to the workspace under either setting and
load as parsed. Entries of installed packages are not diffed against anything
and keep the old behavior.
@robobun
robobun force-pushed the farm/0bb5d6ef/bun-lock-workspace-dep-rows branch from 4eb1d80 to 0097912 Compare August 15, 2026 11:10
Comment on lines +3492 to +3497
/// Whether `Package::parse_dependency` links `dep` to the workspace `bun.lock`
/// bound it to, so that `Diff::generate` sees the loaded and the parsed
/// dependency as equal. With `linkWorkspacePackages` off it links no range, and
/// a range still bound to a workspace is loaded as linked on purpose: the diff
/// re-resolves it. Peers, overridden entries and dist-tags bind to a workspace
/// either way.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +3537 to +3538
/// Same exemption as `enqueue_dependency_with_main_and_success_fn`: an `npm:`
/// alias is never overridden.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Squashed and rebased onto current main (0097912; the only conflict was the new may_stay_unresolved helper landing next to the added functions). Two changes in the same push, after comparing the rule with the loader change inside #33632:

  • With linking off, an entry an override applies to now also loads as parsed (an npm: alias is never overridden, matching the resolver). An override such as "no-deps": "workspace:*" binds a range to the workspace under either setting, so marking it would have diffed on every install, as it does on main; two table rows cover it (linking on, where the unsatisfied range already loaded as parsed, and linking off, which needs the exemption: without it the row fails on the --verbose line with the re-resolve binding the workspace again).
  • The test table notes that the dump loads with the test process's own settings, so for the linking-off rows the --verbose install in the fixture is the assertion that exercises that setting.

23 tests now; 19 fail against main's bun.lock.rs. Left notes on #33632, #37249 and #37264 about how they relate to this one (details in the description's overlap bullet). The earlier review items (catalog/dist-tag with linking off, the name compare) are unchanged in the squashed commit.

Comment on lines +3539 to +3542
fn overridden(&self, dep: &Dependency) -> bool {
let is_alias = dep.version.tag == DependencyVersionTag::Npm && dep.version.npm().is_alias;
!is_alias && self.overrides.has_rule_for_name(dep.name_hash)
}

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.

🟡 overridden() uses has_rule_for_name(dep.name_hash), which also matches scoped/range-gated overrides (scoped_names) whose parent scope does not apply to this edge. In the !link_workspace_packages branch that makes links() return false for a plain range that was previously linked, so it loads as Npm, matches the parser, never diffs, and stays workspace-bound instead of re-resolving against the registry — a regression from pre-PR behavior for the (obscure) combination of a scoped override on a workspace-named package plus toggling linkWorkspacePackages off. enqueue_dependency_with_main_and_success_fn uses the precise overrides.get(&lockfile, id, name_hash) for the same distinction.

Extended reasoning...

What the bug is

WorkspaceLinks::overridden() (src/install/lockfile/bun.lock.rs:3539-3542) exists to tell the !link_workspace_packages branch of links() whether an npm range that bun.lock bound to a workspace got there because an override sent it there (in which case re-resolving would just bind the workspace again, so it should load as parsed and not diff) or because linking was previously on (in which case it should keep loading as linked, diff once, and re-resolve against the registry). It answers that question with self.overrides.has_rule_for_name(dep.name_hash).

OverrideMap::has_rule_for_name (src/install/lockfile/OverrideMap.rs:316-318) is self.map.contains(&name_hash) || self.scoped_names.contains(&name_hash). scoped_names is populated by push_scoped (line 511) with the target name of every scoped and range-gated rule, regardless of its parent scope — so a rule like "some-transitive-pkg > no-deps": "2.0.0" or "no-deps@^2": "..." puts hash("no-deps") into scoped_names even though overrides.get() returns None for a direct pkg1 → no-deps edge that does not match the scope.

The code path that triggers it

In links() with link_workspace_packages == false, after the Workspace-tag early return:

if dep.behavior.is_peer() || self.overridden(dep) {
    return false;
}

When overridden() over-broadly returns true, links() returns false, map_manifest_dep_to_pkg does not call link_dep_to_workspace, and the dependency stays tagged Npm. The parser (linking off) also produces Npm, so Diff::generate sees no change and the resolution recorded in bun.lock — the workspace package — is kept.

Why existing code does not prevent it

The precise check for "does an override apply to this edge" is overrides.get(&lockfile, dependency_id, name_hash), which walks the parent chain and matches scoped rules only when the scope actually applies (OverrideMap.rs:152-157). That is what enqueue_dependency_with_main_and_success_fn uses for the same distinction. has_rule_for_name was borrowed from resolve_peer_dep_version_based, where a false positive falls through to the tree-path lookup and is harmless; here there is no fallback — a false positive silently keeps the wrong resolution.

WorkspaceLinks does not currently have access to the dependency_id or the lockfile parent chain, so wiring the precise check in is not a one-liner, but the coarse check is still an incompleteness in the "load as parsed" rule the PR establishes.

Impact

A regression from pre-PR behavior in a narrow case. Concretely:

  • Root has overrides: { "other-pkg > no-deps": "2.0.0" } (or "no-deps@^2": "...").
  • Workspace no-deps@1.0.0 at packages/pkg2; pkg1.dependencies: { "no-deps": "^1.0.0" }. The scoped override does not apply to pkg1 → no-deps.
  • Install with linkWorkspacePackages on: parser links ^1.0.0 → resolver binds the workspace → bun.lock records it workspace-bound.
  • User turns linkWorkspacePackages off and installs again.
  • Loader: dep.version.tag == Npm, res.tag == Workspace, linking off. overridden(dep) → has_rule_for_name(hash("no-deps")) → true (via scoped_names) → links() returns false → dep stays Npm.
  • Parser (linking off) also produces Npm. No diff. pkg1 → no-deps stays bound to the workspace and is never re-resolved against the registry.

Before this PR, map_dep_to_pkg unconditionally retagged the entry to Workspace when res.tag == Workspace, so with linking off the diff fired and re-resolved to the registry on the next install. The PR's own a linked range (^1.0.0) is re-resolved after linkWorkspacePackages is turned off test would fail if a scoped override on no-deps (under an unrelated parent) were added to the fixture.

The trigger is a triple conjunction — a scoped/range-gated override whose target name matches a workspace, a workspace declaring a satisfied npm range on that name, and the user toggling linkWorkspacePackages off — and the workaround (delete bun.lock) is easy, so this is not worth blocking merge over. But it is a real gap in the rule and worth noting.

How to fix

Give WorkspaceLinks::overridden() (or links()) enough context to call the precise overrides.get(lockfile, dep_id, name_hash) instead of has_rule_for_name — e.g. pass the dependency_id down from map_manifest_dep_to_pkg and hold a reference to lockfile (or the parent chain). Then overridden() returns true only when an override actually applies to this edge, matching the exemption enqueue_dependency_with_main_and_success_fn already uses.

Step-by-step proof

  1. Root package.json: { "workspaces": ["packages/*"], "overrides": { "unrelated > no-deps": "2.0.0" } }.
  2. packages/pkg2/package.json: { "name": "no-deps", "version": "1.0.0" }.
  3. packages/pkg1/package.json: { "name": "pkg1", "dependencies": { "no-deps": "^1.0.0" } }.
  4. bun install (linking on). Package::parse_dependency sees ^1.0.0, link_workspace_packages && satisfies → links. bun.lock records pkg1 > no-deps → workspace.
  5. Loading bun.lock: parse_overrides → put_lockfile_rule → push_scoped inserts hash("no-deps") into lockfile.overrides.scoped_names.
  6. Set install.linkWorkspacePackages = false in bunfig.toml; bun install.
  7. map_manifest_dep_to_pkg on pkg1's no-deps: res.tag == Workspace, calls workspace_links.links(dep, pkg_id).
  8. links(): dep.version.tag == Npm (not Workspace), !self.link_workspace_packages → checks dep.behavior.is_peer() (false) || self.overridden(dep).
  9. overridden(dep): is_alias = false, self.overrides.has_rule_for_name(hash("no-deps")) → scoped_names.contains(...) = true → returns true.
  10. links() returns false. link_dep_to_workspace is skipped. Loaded dep stays tag = Npm.
  11. Diff::generate: parsed dep (linking off, override does not apply here per overrides.get() returning None) is also tag = Npm, Dependency::eql passes. summary.update = 0.
  12. Resolution stays workspace-bound. Pre-PR, step 8 unconditionally retagged to Workspace, step 11 saw Workspace vs Npm, summary.update = 1, and re-resolution went to the registry.

@robobun

robobun commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator Author

Issue #40366 is the same root cause surfaced through bun prune and bun dedupe: the skipped rewrite on a duplicate name makes Diff::generate report the lockfile as stale, and both commands exit 1 with "bun.lock does not match package.json" on a lockfile bun install just wrote. I reproduced it and fixed it independently on branch farm/a9e82d7a/prune-dedupe-workspace-peer-dup (https://github.com/oven-sh/bun/tree/farm/a9e82d7a/prune-dedupe-workspace-peer-dup) with a narrower change: keep the seen_deps shortcut but apply the workspace rewrite when the duplicate itself resolves to the workspace. This PR covers that fix and more, so I am not opening a second PR. The branch also carries a prune/dedupe regression test in test/cli/install/bun-workspaces.test.ts ("workspace dependency duplicated as a peer dependency stays in sync") that this PR could adopt to close #40366.

@robobun

robobun commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Status after #41302 (merged as f942cb0): the bug in this PR is fixed on main. Three of the new cases still fail on main, so this PR stays open for now.

I checked with a debug build of main (4661e49):

  • The repro from this PR no longer reproduces. bun install --verbose does not report packages/b, and bun prune exits 0.
  • 20 of the 23 new cases pass on main without changes.

These cases fail on main:

  1. a range on a workspace without a version. Only the loaded tag differs. Main loads "*" on a versionless workspace as workspace packages/pkg2, because the parser now links that range too. With that expected value, the case passes, and the second install reports no change.
  2. a linked range (^1.0.0) is re-resolved after linkWorkspacePackages is turned off
  3. a linked range (catalog:) is re-resolved after linkWorkspacePackages is turned off

For cases 2 and 3, main keeps the workspace link. The install with linkWorkspacePackages = false reports no change and does not save the lockfile. Without a bun.lock, the same install resolves ^1.0.0 from the registry. Case 2 passed on a build of main before #41302 (e0a2b82).

The #41302 description states this behavior: "With linkWorkspacePackages = false, edges an earlier lockfile linked stay linked until the lockfile is regenerated." A maintainer needs to decide if the re-resolution is still wanted.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

This is Claude (an AI assistant) writing on Jarred's behalf.

Obsolete: fixed on main by #41302.

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.

2 participants