Skip to content

install: keep bundleDependencies from every dependency group when parsing a registry manifest - #38857

Open
robobun wants to merge 3 commits into
mainfrom
farm/ed83cb74/npm-manifest-bundled-deps-all-groups
Open

robobun wants to merge 3 commits into
mainfrom
farm/ed83cb74/npm-manifest-bundled-deps-all-groups

Conversation

@robobun

@robobun robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A registry manifest version with bundleDependencies plus both dependencies and optionalDependencies loses the bundled names declared in dependencies: bun install downloads them from the registry and hoists them next to the copies the tarball already ships, and bun.lock records them as ordinary entries instead of "<parent>/<name>" with "bundled": true.
  • A bundled name declared in peerDependencies is never treated as bundled from a manifest, so the peer is installed from the registry even though the tarball ships it.
  • Cause: PackageManifest::parse (src/install/npm.rs) filtered the bundleDependencies array against the dependency groups while walking them, and each non-peer group assigned package_version.bundled_dependencies to just the slice of bundled_deps_buf it had written, so the optionalDependencies group replaced what the dependencies group produced, and the peer group wrote names that nothing published (the !is_peer guard from install: global virtual store for isolated linker (7x faster warm installs) #29489 only stopped it from replacing the list with an empty slice).
  • Present since bundled dependency support landed (handle bundle(d)Dependencies in bun install #16055); not a port regression.

Fix

  • The parser now stores the array as published: one name hash per listed string, written once per version before the dependency groups are walked (bundleDependencies: true still stores INVALID). The per-group filtering (bundled_deps_set), the per-group slice assignment and the raw write inside the dependency loop are gone; the counting pass sizes the buffer from the array length.
  • This is correct because Package::from_npm (src/install/lockfile/Package.rs, the loop over bundled_dependencies) is the only reader of the list and already matches it against the dependencies of every group by name hash. The parser's filtering was redundant, and it was the mechanism that lost names. The stored hashes come from Builder::string_hash, the function that also hashes the dependency names from_npm compares against. A listed name that no group declares never matches anything; the package.json parser used for folder, tarball and git packages (Package.rs, bundled_deps.contains) and the package-lock migration already work this way, as does npm's arborist.
  • Behavior change to be aware of: a bundled name declared in peerDependencies is now bundled when the package comes from a registry manifest, which is what already happened for the same package installed as a tarball, git or folder dependency and for bundleDependencies: true. The bun.lock loader already re-marks bundled edges of every group; install: keep bundled peer edges on their own bun.lock entry when loading #38837 (separate file, bun.lock.rs) fixes how bundled peer edges are bound when a lockfile is loaded, and the frozen reinstall in the tests here passes with or without it.
  • The manifest cache header moves to v0.0.8 (same length, the format itself is unchanged): the contents of bundled_deps_buf changed, and a 304 revalidation reuses the parsed entry, so without the bump machines with a warm cache would keep the old lists until the packument changes.
  • Tests: test/cli/install/bun-install-registry.test.ts, bundledDependencies describe, with two new registry fixtures. bundled-with-optional has dependencies { no-deps }, optionalDependencies { a-dep, basic-1 }, bundleDependencies [no-deps, a-dep, not-a-dependency] (the last name is declared in no group and does not exist in the registry) and ships no-deps and a-dep; bundled-peer has peerDependencies { no-deps }, bundleDependencies [no-deps] and ships no-deps. Each test runs under bun.lock and bun.lockb, checks the layout and the lockfile, then deletes node_modules and reinstalls with --frozen-lockfile. Both fail on the current release (no-deps shows up in the root node_modules) and pass with this change.
  • Also run with the final build: all of bun-install-registry.test.ts (246 pass), bun-lock.test.ts, bun-prune, bun-pm-licenses and migration/migrate.test.ts; both fixtures also checked by hand under the isolated linker. bun-dedupe, bun-audit and bun-update-transitive passed in full on the first revision; on the final build, with the host at a load average around 200, they only produced per-test timeouts (no assertion failures, and the timed-out tests pass when run individually).

Background

  • A bundled dependency is one the package ships inside its own node_modules in the tarball and lists in bundleDependencies. bun marks that edge Behavior::BUNDLED: it is not downloaded or hoisted, the tree places it directly under its parent, and bun.lock prints it as "<parent>/<name>" with "bundled": true.
  • PackageManifest::parse converts a registry packument into the cached .npm manifest in two passes: a counting pass that sizes flat buffers, then a build pass that fills them. Per-version fields are (offset, len) windows into those buffers (ExternalSlice); bundled_dependencies is such a window into bundled_deps_buf, and Package::from_npm sets BUNDLED on every dependency whose name hash appears in the window.
  • The .npm files start with a version header (Serializer::HEADER_BYTES); a file with a different header is discarded and the packument is fetched again. Entries with a matching header are reused as parsed, including after a 304 from the registry.
First revision of this PR

The first revision kept the per-group filtering and only moved the slice start before the group loop, assigning the combined range once after it. Review pointed out that the filtering is redundant given from_npm, and that the changed buffer contents need a cache header bump; the current revision does both.

Failure on the current release
USE_SYSTEM_BUN=1 bun test test/cli/install/bun-install-registry.test.ts -t "bundled name"

(fail) bundledDependencies > (bun.lock) bundled names from dependencies and optionalDependencies
  [
    "basic-1",
    "bundled-with-optional",
+   "no-deps",
  ]
(fail) bundledDependencies > (bun.lock) bundled name declared in peerDependencies
  [
    "bundled-peer",
+   "no-deps",
  ]

a-dep, the bundled name declared in optionalDependencies, is correctly absent from the root in the first case: the list the optional group produced is the one that survived.

…ncy groups when parsing a manifest

PackageManifest::parse sliced bundled_deps_buf once per dependency group
and assigned the slice to package_version.bundled_dependencies on every
non-peer group, so a version with both dependencies and
optionalDependencies kept only the bundled names declared in
optionalDependencies, and names declared in peerDependencies were never
kept. Capture the buffer offset once per version and assign the range
written across all groups after the group loop.

Adds the bundled-with-optional and bundled-peer registry fixtures and
tests for both layouts in bun-install-registry.test.ts.
@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: 21 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: 5db8834a-c3f1-4864-a3c8-b7339192e348

📥 Commits

Reviewing files that changed from the base of the PR and between 39fb3c1 and 1547eeb.

📒 Files selected for processing (6)
  • src/install/npm.rs
  • test/cli/install/bun-install-registry.test.ts
  • test/cli/install/registry/packages/bundled-peer/bundled-peer-1.0.0.tgz
  • test/cli/install/registry/packages/bundled-peer/package.json
  • test/cli/install/registry/packages/bundled-with-optional/bundled-with-optional-1.0.0.tgz
  • test/cli/install/registry/packages/bundled-with-optional/package.json

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

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:05 AM PT - Aug 15th, 2026

❌ @robobun, your commit 1547eeb has some failures in Build #97935 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38857

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

bun-38857 --bun

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on the current release with the two fixtures added here (USE_SYSTEM_BUN=1 bun test test/cli/install/bun-install-registry.test.ts -t "bundled name"): no-deps is installed at the root of node_modules in both cases, although the parent package's tarball ships it. The new tests pass with this branch's debug build, as do bun-install-registry.test.ts as a whole, bun-lock.test.ts and the other suites listed in the description.

Current revision (after review): the parser stores the bundleDependencies array as published and leaves the matching to Package::from_npm, and the manifest cache header is bumped to v0.0.8 so entries parsed by earlier builds are re-parsed. A bundle name declared in peerDependencies is bundled from a registry manifest now, as it already was for tarball, git and folder packages; see the description.

CI: on both the previous sha and the current one (1547eeb), every lane that ran passed (177 jobs each time, no failing tests; the only annotations are retried tests unrelated to this change). The two darwin 14 aarch64 - test-bun jobs have not been able to start in either build for lack of a macOS agent, which is why the builds do not show green; that lane has nothing platform-specific to exercise here.

@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 reviewed this PR and didn't find any bugs. The fix is small and I've verified it's consistent with the counting pass, but since it changes install behavior (bundled peers now honored from manifests) and consciously skips a manifest cache-version bump, a human sign-off would still be worthwhile.

What was reviewed:

  • Confirmed the counting pass at npm.rs:2176-2194 already sizes bundled_deps_buf across all three groups, so hoisting bundled_deps_begin and assigning once after the loop cannot overrun the buffer.
  • Checked that bundled_deps_set.swap_remove (line 2697) already ran for peers before this change — the buffer was being written, just never published — and that swap_remove prevents duplicate hashes when a name appears in multiple groups.
  • Checked the now-unconditional assignment: when no group has items, bundled_deps_begin == bundled_deps_offset yields an empty list (matching the previous default), and bundle_all_deps is reset per version so INVALID handling is preserved.
Extended reasoning...

Overview

The PR fixes PackageManifest::parse in src/install/npm.rs so that a version's bundled_dependencies list covers names matched from all dependency groups instead of only the last non-peer group iterated. The change hoists let bundled_deps_begin = bundled_deps_offset; from inside the per-group loop to just before it, removes the per-group if !is_peer { ... } assignment block, and adds a single unconditional assignment after the loop. Two new registry fixtures and four new test cases (two tests × bun.lock/bun.lockb) are added to bun-install-registry.test.ts.

Security risks

None. This is dependency-graph bookkeeping in the manifest parser — no untrusted-input parsing surface changes, no new syscalls or allocations. The buffer write at line 2700 is unchanged; I verified the counting pass at lines 2176–2194 already iterates all DEPENDENCY_GROUPS (including peers) when sizing bundled_deps_count, so the hoisted range cannot exceed the pre-sized buffer.

Level of scrutiny

Medium-high. The code change itself is mechanical and I'm confident it's correct — the counting pass and build pass were already symmetric across groups; only the publishing of the slice was per-group. But this is the package-manager manifest parser, executed on every registry install, and the PR carries two decisions a human should acknowledge: (1) bundled names declared only in peerDependencies are now honored from manifests (a behavior change, though it aligns with the tarball/git/folder path and bundleDependencies: true), and (2) the manifest cache header is deliberately not bumped, so users with stale cached manifests keep the old bundled list until the packument changes.

Other factors

The PR description is unusually thorough — it names the root cause, cites the sibling code paths (Package::from_npm, the package.json parser, bun pm pack, npm arborist) that already treat bundleDependencies as group-agnostic, and lists the test suites that were re-run. Tests follow the existing bundledDependencies describe pattern, assert exact node_modules layout and lockfile contents, and re-verify with --frozen-lockfile after wiping node_modules. No prior human review comments, no CODEOWNERS on this path, and the bug hunting system found nothing.

…manifest cache to v0.0.8

The manifest parser no longer filters bundleDependencies against the
dependency groups while it walks them; it writes the hash of every name
in the array once per version, and Package::from_npm keeps doing the
matching against the dependencies of every group. This removes the
per-group slicing that lost names, the StringSet used to filter them,
and the raw write inside the dependency loop.

The cache header moves to v0.0.8 so .npm entries written with the old
lists are re-parsed instead of being reused on a 304.

The bundled-with-optional fixture now also lists a name that no group
declares, which must have no effect.
Comment thread src/install/npm.rs Outdated
Comment thread src/install/npm.rs 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.

I reviewed this PR and didn't find any bugs. Because it changes how the manifest parser stores bundleDependencies (with a user-visible behavior change for bundled peers from registry manifests) and bumps the manifest cache header, a human look would still be worthwhile.

What was reviewed:

  • Counting-pass and build-pass agreement — both filter on as_str().is_some(), so bundled_deps_buf is sized exactly and the indexed write cannot go past the end.
  • Package::from_npm is the only reader of bundled_dependencies and matches by name.hash (same Builder::string_hash), so unfiltered extra names and not-a-dependency are harmless.
  • Boolean(true) → INVALID still maps to all_dependencies_bundled(); false/absent falls through to default() as before via empty_version.
  • StringSet import removal — no remaining use in npm.rs.
Extended reasoning...

Overview

This PR fixes PackageManifest::parse in src/install/npm.rs: previously each dependency group (dependencies, optionalDependencies) overwrote package_version.bundled_dependencies with only the slice it produced, so bundled names declared in dependencies were lost when optionalDependencies was also present, and bundled names declared in peerDependencies were never recorded. The fix stores the bundleDependencies array as-published (one name hash per string entry) once per version before walking the groups, and drops the per-group filtering entirely — Package::from_npm already does the name-hash match against every group. The manifest cache header is bumped to v0.0.8 so warm caches with the old buffer contents are re-parsed. Two new registry fixtures and four new tests (bun.lock × bun.lockb) exercise both bug shapes plus a bundled name declared in no group.

Security risks

None identified. The parser reads registry JSON into a pre-sized buffer; the counting pass and build pass use the same predicate so the buffer cannot be overrun. No new syscalls, no new external inputs, no auth or crypto touched.

Level of scrutiny

Medium-high. bun install manifest parsing is a production hot path used by every registry install, and this change intentionally alters behavior: a bundled name declared only in peerDependencies is now marked BUNDLED when the package comes from a registry manifest (previously it was not). The PR description argues this aligns the registry path with the tarball/git/folder path and with npm's arborist, which I verified against Package.rs — but a maintainer should confirm they want that alignment. The cache-header bump is also a one-time cost for every user's .npm cache on upgrade.

Other factors

The change is well-argued, the tests are strong (they check node_modules layout, the lockfile snapshot, and a --frozen-lockfile reinstall, and were shown to fail on the current release), and the code is simpler than what it replaces (net −46 lines, drops StringSet, an unsafe block, and two long explanatory comments). The two comment-cop bot flags were addressed in 1547eeb. I am deferring rather than approving because the behavior change for bundled peers and the cache invalidation are decisions a maintainer should explicitly sign off on, not because I found anything wrong.

@robobun

robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

#43125 carries the parser change of this PR, rebased onto current main: src/install/npm.rs, the bundled-with-optional and bundled-peer fixtures, and their two tests. It needs the change: a bundled file: dependency in dependencies loses its bundled flag when the package also has optionalDependencies (@vellumai/assistant@0.12.2), and the install then refuses its path. If #43125 merges first, this PR can be closed.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant