Skip to content

install: make PackageID and DependencyID newtypes and index the lockfile buffers by them - #39181

Closed
robobun wants to merge 9 commits into
mainfrom
farm/bc4bffa4/install-id-newtypes
Closed

robobun wants to merge 9 commits into
mainfrom
farm/bc4bffa4/install-id-newtypes

Conversation

@robobun

@robobun robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • PackageID and DependencyID were both type X = u32; aliases (src/install_types/resolver_hooks.rs), so a package id used where a dependency id belongs compiled silently. mordant's interchangeable_aliases flagged one such site, ROOT_DEP_ID: DependencyID = invalid_package_id - 1 in src/install/lockfile/Tree.rs.
  • Making the two ids distinct types turned up more of the same, none of which the lint could see. All of them hold the same numeric value as before, so none is a behavior bug, but each is a place where the declared type lied:
    • PackageManager::FailFn took a PackageID; its only implementation (fail_root_resolution) and every caller pass a dependency id.
    • lockfile::PendingResolution::resolve_id was a PackageID; it indexes buffers.resolutions, i.e. it is a dependency id.
    • tree::Builder::resolution_lists and Tree::hoist_dependency's range parameter were typed as DependencyIDSlice but are fed Package.resolutions (PackageIDSlice); verify_resolutions annotated the same column the same way; yarn.rs built Package.resolutions as DependencyIDSlice::new(..) in four places; Tree::EXTERNAL_SIZE sized the dependency_id slot with size_of::<PackageID>().
    • INVALID_PACKAGE_ID served as the dependency-id sentinel in printer/tree_printer.rs (4 sites) and in the resolver's PendingResolution::default().
    • hooks::TaskCallbackContext::root_request_id was a bare u32 while the bun_install side is a PackageID.
    • Lockfile::loaded_package_count and a few local counters were typed as PackageID but are counts.
    • Buffers::load's legacy-lockfile path reads package ids out of DependencyID slots; that reinterpretation is now explicit.
  • With the ids typed, the buffers they index were still plain slices: about 600 column[id.index()] subscripts accepted any usize, so the types guarded parameters and fields but not the indexing itself (review feedback on the first version of this PR).

Fix

Commits, in order (plus three small follow-ups from review: stale comments, the yarn capacity pointers described under typed indexing, and unit tests for IdSlice / IdVec and the indexed by column traits in bun_collections, which the cargo miri test job runs, so the from_raw casts and the wrapped split_mut columns are interpreted under Tree Borrows in CI rather than only compiled):

  • PackageID and DependencyID become #[repr(transparent)] newtypes over u32 with new, from_index (truncating, like the as casts it replaces), TryFrom<usize> (for the existing checked sites), get, index, INVALID, and PackageID::ROOT. Debug/Display print the bare number, so log lines, bun pm ls-style output and the debug lockfile JSON are unchanged. They are bytemuck::Pod so the bun.lockb buffers and index_sort keep working on them, and ArrayIdentityContext is implemented for them so the identity-hashed maps keyed by ids keep their context. INVALID_PACKAGE_ID / invalid_package_id etc. keep their names. DependencySlice and ResolutionSlice (the two slices whose positions are dependency ids) gain dependency_ids() and a typed contains(DependencyID); the untyped ExternalSlice::contains(u32) had no other callers and is removed. The mislabeled declarations above get the type their values actually have; on-disk layout is unaffected (padding_checker.rs pins both types at 4 bytes next to the other serialized leaf types).
  • mordant-baseline.toml loses the interchangeable_aliases:src/install/lockfile/Tree.rs entry; the lint only looks at integer aliases, so the newtypes make that site (and any future one) impossible to write. bun run rust:mordant on this branch reports nothing over the baseline.
  • Default for both ids is INVALID, like NewId in the isolated installer and bun_ast::Index, instead of the derived 0 (which is the root package). The only consumers of Default are the get_or_put fills in the bun.lock / pnpm parsers, which every path overwrites or abandons on error; the scratch buffers that used to be zero-filled now say invalid_package_id explicitly, and the two structs that derived Default only to hold an id did not use it (derives dropped).
  • Typed indexing: bun_collections::IdSlice<I, T> / IdVec<I, T> are a slice and a vector subscripted with an id type I: Idx (implemented by both ids). They deref to [T] for everything that is not subscripting, so len/iter/&[T] parameters/byte casts need no changes, but because the wrapper has its own Index impl, s[0], s[some_usize] and s[a..b] are compile errors; raw positions go through raw() / raw_mut() (24 sites: sub-ranges being filled, sorted or walked, the Vec being handed to the capacity-growing helpers, and the yarn migration's capacity fill, which takes its base pointers from the Vecs because the slice's as_mut_ptr reached through Deref would only cover len; that last point is also called out in IdVec's docs). multi_array_columns! accepts for Package<_>, indexed by PackageID, so every items_*() accessor (and split_mut) hands out IdSlice<PackageID, _>; buffers.dependencies / buffers.resolutions are IdVec<DependencyID, _>; the resolver hooks, tree::Builder, reachable::Walk, the dedupe pass, the printers, PackageInstaller's column back-references (now BackRef<IdSlice<..>> instead of RawSlice), and the per-package / per-dependency scratch vectors (preinstall_state, the clone mapping, dedupe's group_of, audit's parent_of, ...) follow. x[id.index()] no longer occurs anywhere in src/install or the bun pm commands; loops that counted 0..len and converted use ids() / iter_enumerated(); Lockfile::package(id) gathers a row (the one remaining packages.get(id.index())); the .index() calls left feed bitsets and packages.len() bounds checks. has(id) replaces the id.index() < col.len() checks against typed slices.
  • test/internal/source-lints/install-id-indexing.test.ts keeps this in place: it checks that the two ids are newtypes, that the package columns and the dependency buffers are declared id-indexed, and that no buffer[id.index()] / buffer.get(id.index()) subscript is left in the install crate or the pm commands (the two exceptions above are allow-listed with exact counts). It fails on main's sources and passes here.
  • One deliberate pun is unchanged: the id_mapping slice passed to Diff::generate stores positions within the old root package's dependency list in a [PackageID], and install_with_manager reads it back with .index() into a sub-slice. It is internally consistent and a separate cleanup.
  • Verified:
    • cargo check --workspace; clippy on bun_collections, bun_install_types, bun_resolver, bun_install, bun_jsc, bun_runtime, bun_ast, bun_bundler (the other multi_array_columns! users); cargo fmt --all; bun run rust:mordant; cargo test -p bun_collections and bun run rust:miri -p bun_collections (42 tests, 7 of them new).
    • Debug build, then bun bd test on about 2,450 tests: the lockfile suites (bun-lockb, bun-lock, migrate-bun-lockb-v2, lockfile-version-2, lockfile-only), test/cli/install/migration/, bun-install, bun-pm, bun-pm-why, bun-pm-licenses, bun-audit, bun-dedupe, bun-prune, bun-add, bun-update-transitive, bun-workspaces, hoist, isolated-install, catalogs, overrides, nested-overrides, frozen-lockfile-pruned, bun-add-filter, bun-install-security-provider, bun-install-registry, bun-update, bun-patch, bun-install-patch, bun-remove, bun-link, and test/cli/update_interactive_*. The failures are the same set before and after every step of this PR and are unrelated to it: tests needing network access (git/bitbucket/gitlab clones, remote tarballs), two migration tests that sit at the 5 s limit on an unoptimized build when run in a batch and pass alone (4.6 s / 2.6 s, same as before this change), and one bun-link expectation that does not account for the #[cfg(bun_debug)] failure trace debug builds print (file untouched here). Details in the block below.

Background

  • The lockfile is a set of flat, parallel buffers. Lockfile.packages is a struct-of-arrays of packages (MultiArrayList, one column per field), indexed by PackageID; package 0 is always the root project. buffers.dependencies holds every declared dependency edge, indexed by DependencyID, and buffers.resolutions is parallel to it: resolutions[dep_id] is the PackageID the edge resolved to (or INVALID_PACKAGE_ID). Each package stores a DependencySlice / PackageIDSlice pair of (offset, len) ranges into those two buffers, which is why the positions covered by either slice are dependency ids, and why a package's own sub-slice of either buffer is position-indexed rather than id-indexed (those stay plain slices).
  • bun.lockb serializes buffers.resolutions and buffers.hoisted_dependencies as raw u32 arrays, and Tree (one per node_modules folder) as 20 raw bytes including a DependencyID; repr(transparent) keeps those byte-for-byte identical. Very old lockfiles stored package ids in the tree's dependency-id slots, which is the legacy conversion in Buffers::load.
  • ROOT_DEP_ID is a sentinel dependency id given to the root node_modules tree node, which has no dependency edge of its own; Tree::process_subtree maps it to package 0. Its value (u32::MAX - 1) is unchanged; it is now derived from the dependency-id sentinel rather than the package-id one.
  • Why IdSlice can deref to [T] and still reject s[0]: indexing stops at the first type in the auto-deref chain that implements Index at all and then requires the subscript to match one of that type's impls; it does not keep dereferencing to find an impl for a different index type. So IdSlice gets the whole slice API through Deref while subscripting is only possible with its id. multi_array_columns! is the macro that generates the items_<field>() column accessors from a struct's fields; the new indexed by clause wraps each accessor's result in IdSlice::from_raw, which is a pointer cast made sound by #[repr(transparent)].
  • mordant is the dylint lint pack CI runs as a ratchet against mordant-baseline.toml; interchangeable_aliases reports a value declared under one integer alias flowing into a slot declared under another.
Local test runs (unoptimized debug build)
test/cli/install/bun-install.test.ts (14 of 714 in that batch; same 14 fail with USE_SYSTEM_BUN=1 and with the
first version of this branch):
  should support --registry CLI flag
  should handle bitbucket git dependencies > install/add bitbucket:..., bitbucket.org:..., bitbucket.com:..., git@bitbucket.org:...  (8)
  should handle gitlab git dependencies > install/add gitlab:..., gitlab.com:...  (4)
  should treat non-GitHub http(s) URLs as tarballs (https://some.url/path?stuff)
test/cli/install/migration/complex-workspace.test.ts:
  "the install succeeds" and its dependents: `"git clone" for "install-test" failed` (same with USE_SYSTEM_BUN=1)
test/cli/install/migration/yarn-lock-migration.test.ts > yarn-cli-repo, pnpm-migration-complete.test.ts:
  5 s timeouts when run as part of a 13-file batch; 4.6 s and 2.6 s when run alone (4.9 s / 2.8 s before the typed
  slices), so no measurable cost from the wrappers even at opt-level 0
test/cli/install/bun-link.test.ts > should link dependency without crashing:
  stdout contains the failure trace that Failure::boxed captures under cfg(bun_debug); the test expects the release
  output. PackageInstall.rs is not touched by this PR.

[review] gate passed · iteration 1 · 71 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/internal/source-lints/install-id-indexing.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/26] gen generated_host_exports.rs
generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 240 extern-C blocks audited
[2/26] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (15 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes.ts
  - Glob (5 fields)
Found 1 classes from /work
... (truncated)

release without fix: 3 FAILED
bun test v1.4.0-canary.1 (a13e861e9)

test/internal/source-lints/install-id-indexing.test.ts:
20 |   return file(path.join(root, rel)).text();
21 | }
22 | 
23 | test("PackageID and DependencyID are newtypes, not integer aliases", async () => {
24 |   const hooks = await read("src/install_types/resolver_hooks.rs");
25 |   expect(hooks).toMatch(/^pub struct PackageID\(u32\);/m);
                     ^
error: expect(received).toMatch(expected)

Expected substring or pattern: /^pub struct PackageID\(u32\);/m
Received: "//! Shared install↔resolver type surface.\n//!\n//! MOVE_DOWN from `bun_install` so `bun_resolver` can spell these types\n//! without an upward dep edge (resolver→install would cycle through\n//! install→resolver). The behaviourful `PackageManager` itself stays in\n//! `bun_install`; the resolver talks to it through the [`AutoInstaller`]\n//! trait below, which `bun_install::PackageManager` implements\n//! (`bun_install::auto_installer`).\n//!\n//! Every value type here is the SINGLE canonical definition — `bun_install`\n//! re-exports them (`pub use bun_install_types::…`); there is exactly one\n//! nominal type per name.\n\nuse core::cmp::Order
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/internal/source-lints/install-id-indexing.test.ts
bun test v1.4.0 (88a639883)

test/internal/source-lints/install-id-indexing.test.ts:
(pass) PackageID and DependencyID are newtypes, not integer aliases [11.92ms]
(pass) the package columns and the dependency buffers are indexed by id [19.90ms]
(pass) scans the install crate [10.77ms]
(pass) id-indexed buffers are subscripted with the id, not id.index() [1.75ms]
(pass) allowlisted files still carry exactly their documented count [6.53ms]

 5 pass
 0 fail
 9 expect() calls
Ran 5 tests across 1 file. [3.57s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 829ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/24] gen generated_host_exports.rs
generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 240 extern-C blocks audited
[2/24] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (15 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Glob.classes.ts
  - Glob (5 fields)
Found 1 classes from /workspace/bun/src/runtime/api/h2.classes.ts
  - H2FrameParser (32 fields)
Found 9 cl
... (truncated)
diff hotspot
Cargo.lock                                         |   1 +
 mordant-baseline.toml                              |   1 -
 src/collections/id_slice.rs                        | 407 +++++++++++++++++++++
 src/collections/lib.rs                             |   2 +
 src/collections/multi_array_list.rs                | 196 ++++++++--
 src/install/PackageInstaller.rs                    | 170 ++++-----
 src/install/PackageManager.rs                      |  14 +-
 src/install/PackageManager/PackageJSONEditor.rs    |  21 +-
 .../PackageManager/PackageManagerDirectories.rs    |   2 +-
 .../PackageManager/PackageManagerEnqueue.rs        | 111 +++---
 .../PackageManager/PackageManagerLifecycle.rs      |  23 +-
 .../PackageManager/PackageManagerResolution.rs     |  49 ++-
 .../PackageManager/PopulateManifestCache.rs        |  21 +-
 src/install/PackageManager/UpdateRequest.rs        |   2 +-
 src/install/PackageManager/add_catalog.rs          |  10 +-
 .../PackageManager/add_remove_with_filter.rs       |   2 +-
 src/install/PackageManager/install_with_manager.rs | 115 +++---
 .../PackageManager/package_json_write_back.rs      |  10 +-
 src/install/PackageManager/patchPackage.rs         |  30 +-
 .../PackageManager/processDependencyList.rs        |  31 +-
 src/install/PackageManager/runTasks.rs             |  46 ++-
 src/install/PackageManager/security_scanner.rs     |  78 ++--
 .../PackageManager/updatePackageJSONAndInstall.rs  |   9 +-
 src/install/PackageManager/workspace_selection.rs  |  21 +-
 src/install/audit_fix.rs                           |  65 ++--
 src/install/audit_fix/package_json_edits.rs        |   8 +-
 src/install/auto_installer.rs                      |  17 +-
 src/install/bin.rs                                 |  11 +-
 src/install/dedupe.rs                              | 115 +++---
 src/install/hoisted_install.rs                     |  30 +-
 src/install/isolated_install.rs                    | 241 ++++++------
 src/install/isolated_install/Insta
... (truncated)

gate history · 2 passed · 1 rejected · iteration 1

evidence per changed file
file                                                     reads  edits  tests
Cargo.lock                                                   0      0      0
mordant-baseline.toml                                        2      2      0
src/collections/id_slice.rs                                  4      8      0
src/collections/lib.rs                                       1      3      0
src/collections/multi_array_list.rs                          6      7      0
src/install/PackageInstaller.rs                              0      0      0
src/install/PackageManager.rs                                2      2      0
src/install/PackageManager/PackageJSONEditor.rs              0      0      0
src/install/PackageManager/PackageManagerDirectories.rs      0      0      0
src/install/PackageManager/PackageManagerEnqueue.rs          2      5      0
src/install/PackageManager/PackageManagerLifecycle.rs        0      0      0
src/install/PackageManager/PackageManagerResolution.rs       2      4      0
src/install/PackageManager/PopulateManifestCache.rs          0      0      0
src/install/PackageManager/UpdateRequest.rs                  0      0      0
src/install/PackageManager/add_catalog.rs                    0      0      0
src/install/PackageManager/add_remove_with_filter.rs         0      0      0
(+ 55 more files)

@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: 2 minutes

Limit details: You’ve used all 5 included reviews currently available under your plan.

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: d47631da-518c-4ad2-ab5f-8a00d4636ebb

📥 Commits

Reviewing files that changed from the base of the PR and between a42889a and 72b03b5.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (70)
  • mordant-baseline.toml
  • src/collections/id_slice.rs
  • src/collections/lib.rs
  • src/collections/multi_array_list.rs
  • src/install/PackageInstaller.rs
  • src/install/PackageManager.rs
  • src/install/PackageManager/PackageJSONEditor.rs
  • src/install/PackageManager/PackageManagerDirectories.rs
  • src/install/PackageManager/PackageManagerEnqueue.rs
  • src/install/PackageManager/PackageManagerLifecycle.rs
  • src/install/PackageManager/PackageManagerResolution.rs
  • src/install/PackageManager/PopulateManifestCache.rs
  • src/install/PackageManager/UpdateRequest.rs
  • src/install/PackageManager/add_catalog.rs
  • src/install/PackageManager/add_remove_with_filter.rs
  • src/install/PackageManager/install_with_manager.rs
  • src/install/PackageManager/package_json_write_back.rs
  • src/install/PackageManager/patchPackage.rs
  • src/install/PackageManager/processDependencyList.rs
  • src/install/PackageManager/runTasks.rs
  • src/install/PackageManager/security_scanner.rs
  • src/install/PackageManager/updatePackageJSONAndInstall.rs
  • src/install/PackageManager/workspace_selection.rs
  • src/install/audit_fix.rs
  • src/install/audit_fix/package_json_edits.rs
  • src/install/auto_installer.rs
  • src/install/bin.rs
  • src/install/dedupe.rs
  • src/install/hoisted_install.rs
  • src/install/isolated_install.rs
  • src/install/isolated_install/Installer.rs
  • src/install/isolated_install/Store.rs
  • src/install/lib.rs
  • src/install/lockfile.rs
  • src/install/lockfile/Buffers.rs
  • src/install/lockfile/OverrideMap.rs
  • src/install/lockfile/Package.rs
  • src/install/lockfile/Tree.rs
  • src/install/lockfile/bun.lock.rs
  • src/install/lockfile/bun.lockb.rs
  • src/install/lockfile/lockfile_json_stringify_for_debugging.rs
  • src/install/lockfile/printer/Yarn.rs
  • src/install/lockfile/printer/tree_printer.rs
  • src/install/lockfile/pruned_workspaces.rs
  • src/install/lockfile/reachable.rs
  • src/install/migration.rs
  • src/install/migration/npm_lock.rs
  • src/install/padding_checker.rs
  • src/install/patch_install.rs
  • src/install/pnpm.rs
  • src/install/postinstall_optimizer.rs
  • src/install/prune.rs
  • src/install/resolvers/folder_resolver.rs
  • src/install/update_scope.rs
  • src/install/update_transitive.rs
  • src/install/yarn.rs
  • src/install_types/Cargo.toml
  • src/install_types/resolver_hooks.rs
  • src/jsc/AsyncModule.rs
  • src/resolver/package_json.rs
  • src/resolver/resolver.rs
  • src/resolver/result.rs
  • src/runtime/cli/audit_command.rs
  • src/runtime/cli/outdated_command.rs
  • src/runtime/cli/package_manager_command.rs
  • src/runtime/cli/pm_licenses_command.rs
  • src/runtime/cli/pm_trusted_command.rs
  • src/runtime/cli/update_interactive_command.rs
  • src/runtime/cli/why_command.rs
  • test/internal/source-lints/install-id-indexing.test.ts

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

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:58 PM PT - Aug 15th, 2026

@robobun, your commit 72b03b5 is building: #99036

@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. It's a mechanical, compiler-driven newtype refactor, but at 65 files it spans the whole install subsystem including the binary lockfile serialization path, so a human sanity check on the on-disk-layout guarantees is worthwhile.

Checked: the newtype definition (repr(transparent) + Pod, Debug/Display print the bare number, Default = 0); Tree::EXTERNAL_SIZE/to_external/to_tree keep the 20-byte layout; padding_checker.rs pins both ids at 4/4; dependency_ids() covers the same begin()..end() range as the loops it replaces; the removed generic ExternalSlice::contains is fully covered by the typed replacements; the legacy Buffers::load reinterpretation of package ids from dependency-id slots is now explicit and value-identical.

Extended reasoning...

Overview

This PR converts PackageID and DependencyID from type X = u32 aliases into #[repr(transparent)] newtype structs over u32, then follows the compiler through every use site across 65 files: the whole of src/install/ (lockfile serialization, hoisting, isolated install, dedupe, prune, audit, migration from yarn/pnpm/npm), plus src/resolver/, src/jsc/AsyncModule.rs, and seven CLI commands. The core definition lives in src/install_types/resolver_hooks.rs; a bytemuck dep is added there so the ids stay Pod for the raw-buffer serialization and index_sort paths. Everything else is id as usize → id.index(), i as PackageID → PackageID::from_index(i), literal 0 → PackageID::ROOT, plus a handful of type-annotation corrections the newtypes exposed (documented in the description).

Security risks

None identified. This is a type-system refactor with no new I/O, parsing, or trust boundaries. The one area with security-adjacent implications — the on-disk bun.lockb format — is preserved byte-for-byte by repr(transparent) + bytemuck::Pod, and padding_checker.rs now pins both types at size 4 / align 4 to catch any future drift at compile time.

Level of scrutiny

High, because of scope. The change is mechanical and the compiler enforces every conversion, but it touches the binary lockfile round-trip (Tree::to_external/to_tree, Buffers::load's legacy path, bun.lockb.rs), the hoist algorithm, and every install/update/migrate entry point. The PR author ran ~1,900 tests across 24 install suites with only pre-existing network-dependent failures, which is reassuring, but the surface area alone puts this outside what an automated review should approve on its own.

Other factors

  • No prior human reviews on the PR; only bot comments so far.
  • The description is unusually thorough — it enumerates every mislabeled declaration the newtypes exposed and explains why each is value-identical, and calls out the one deliberate remaining pun (Diff::generate's id_mapping).
  • The bug-hunting system found no issues. I spot-checked the higher-risk conversions: Tree::EXTERNAL_SIZE still evaluates to 20 (size_of::<DependencyID>() == size_of::<PackageID>() == 4); ROOT_DEP_ID is still u32::MAX - 1; the bytemuck::cast_slice_mut in dedupe.rs is sound given Pod; Debug/Display delegate to the inner u32 so the debug JSON stringifier and bun pm ls output are unchanged.
  • Given the scale and that it touches the lockfile wire format, deferring to a human for a final look rather than approving.

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: closed unmerged; too large as one change. The alternatives were measured (source diff, non-LTO and LTO code size) in the comment further down; the recommendation is the small relabel-only change, prepared on the farm/bc4bffa4/install-id-labels branch and waiting on a go-ahead before being opened as a PR.

  • Origin: mordant interchangeable_aliases finding on ROOT_DEP_ID in src/install/lockfile/Tree.rs. This branch made PackageID / DependencyID newtypes and typed the buffer indexing; the branch (72b03b5) stays available for reference, and its first commit is the reusable newtype change if that is ever wanted.

@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's a large, compiler-driven refactor across 65 files in the package manager — including the bun.lockb serialization path, two new unsafe impl bytemuck::{Pod,Zeroable} blocks, and a new crate dependency — a human look would still be worthwhile.

What was reviewed:

  • On-disk layout: #[repr(transparent)] + Pod + the new padding_checker pins keep bun.lockb byte-identical; Tree::to_external/to_tree and Buffers::load legacy path round-trip unchanged.
  • ExternalSlice::contains(u32) removal: verified all remaining callers go through the typed DependencySlice/ResolutionSlice::contains(DependencyID).
  • Default = 0, Debug/Display = bare number, ArrayIdentityContext impl — all preserve prior u32 behavior for scratch buffers, log output, and identity-hashed maps.
  • mordant-baseline.toml also drops two unrelated entries (always_unwrapped_option, narrowed_two_ways) from the "regenerate the baseline" commit; those files aren't touched here, so they were presumably fixed on main.
Extended reasoning...

Overview

This PR converts PackageID and DependencyID from type X = u32 aliases into #[repr(transparent)] newtypes over u32, then propagates the compiler-forced fallout across ~60 files in src/install/, src/install_types/, src/resolver/, src/jsc/, and src/runtime/cli/. The core change is ~100 lines in resolver_hooks.rs (newtype definitions with new/from_index/get/index/INVALID/ROOT, TryFrom<usize>, bytemuck::Pod/Zeroable, ArrayHashContext, Debug/Display) plus dependency_ids()/typed contains() on the two slice aliases. Everything else is mechanical: id as usize → id.index(), i as PackageID → PackageID::from_index(i), literal 0 → PackageID::ROOT, and correcting a handful of declarations whose type annotation lied (FailFn, PendingResolution::resolve_id, Builder::resolution_lists, etc.). The PR also adds a bytemuck = "1" dependency to bun_install_types and removes three mordant-baseline entries.

Security risks

None identified. This is a pure type-strengthening refactor with no new I/O, parsing, network, auth, or user-input handling. The two unsafe impl bytemuck::{Zeroable,Pod} blocks are sound (#[repr(transparent)] over u32, which is itself Pod), and padding_checker.rs statically pins both types at size = 4, align = 4. The bytemuck::cast_slice_mut calls in dedupe.rs reinterpret &mut [PackageID] as &mut [u32] for index_sort, which is exactly what Pod licenses.

Level of scrutiny

High. The package manager and its lockfile serialization are among the most load-bearing subsystems in Bun — a subtle mismatch in the bun.lockb binary layout or the Tree::to_external encoding would silently corrupt users' lockfiles. That said, the refactor is compiler-enforced end to end: every as usize / as PackageID site had to be touched to compile, so there is no way for a stale conversion to slip through. #[repr(transparent)] guarantees the on-disk bytes are identical, Default is still 0 so zero-initialized scratch buffers behave the same, and Debug/Display print the bare integer so log/snapshot output is unchanged. The author ran ~1,900 tests across the install/lockfile/migration suites with only pre-existing network-dependent failures.

Other factors

  • The change is large (65 files) and touches critical code paths, which per the approval guidelines means it should get human eyes even when the mechanics look sound.
  • The mordant-baseline.toml regeneration removes two entries unrelated to this PR (always_unwrapped_option:src/install/PackageInstall.rs and narrowed_two_ways:src/runtime/node/node_crypto_binding.rs). Neither file appears in the diff, so those lints presumably stopped firing on main independently; worth a quick sanity check that the regeneration was run against the right base.
  • Removal of the generic ExternalSlice::contains(u32) was verified: all remaining .contains(...) on external slices go through DependencySlice/ResolutionSlice, which now have the typed replacement.
  • No CODEOWNERS conflicts or outstanding reviewer comments; the only timeline entries are bot noise (CodeRabbit rate-limited, robobun build link).

@alii

alii commented Aug 15, 2026

Copy link
Copy Markdown
Member

@robobun fix conflicts

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Merged current main in (96279ba). Two conflicts: isolated_install.rs, where #39129 renamed the DFS indices in the same lines this PR converts (dep.dep_id.index() now sits on the renamed version), and mordant-baseline.toml, which is now main's file minus the Tree.rs interchangeable_aliases line. The merged tree passes cargo check --workspace, clippy, fmt, and bun run rust:mordant reports nothing over the baseline; isolated-install, prune, add, and both lockfile suites pass on the merged debug build.

@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 the automated bug-hunting pass found no issues. Given the size (65 files) and that it touches the lockfile serialization path (Tree::to_external/to_tree, Buffers::load, bytemuck::Pod), a human sign-off would still be worthwhile.

What was reviewed:

  • #[repr(transparent)] + Pod keep the on-disk bun.lockb layout byte-identical; padding_checker.rs now pins both ids at 4 bytes and Tree::EXTERNAL_SIZE is unchanged at 20.
  • ROOT_DEP_ID still evaluates to u32::MAX - 1; Default for both ids is still 0, so vec[PackageID::default(); n] and derived-Default structs keep prior values.
  • The removed generic ExternalSlice::contains(u32) — every remaining caller (OverrideMap, audit_fix, lockfile::is_root_dependency, PackageManagerEnqueue) is on DependencySlice/ResolutionSlice, which have the new typed contains.
  • The while counter_i < changes { … counter_i += 1 } → for counter_i in 0..changes rewrite in install_with_manager.rs preserves iteration count and ordering.
Extended reasoning...

Overview

This PR converts PackageID and DependencyID from type X = u32 aliases into #[repr(transparent)] newtype structs over u32, then propagates the type change through 65 files in the package manager, resolver, and CLI. The vast majority of the diff is mechanical: id as usize → id.index(), i as PackageID → PackageID::from_index(i), literal 0 → PackageID::ROOT, and for i in slice.begin()..slice.end() → for id in slice.dependency_ids(). A handful of declarations whose stated type disagreed with their actual value (e.g. FailFn's parameter, PendingResolution::resolve_id, Builder::resolution_lists) are retyped to match; the numeric values are unchanged. bytemuck is added as a dependency of bun_install_types so the ids remain Pod for the raw-buffer serialization and index_sort paths.

Security risks

None identified. This is a type-level refactor with no new I/O, parsing, or trust boundaries. The unsafe impl Pod/Zeroable blocks are justified by #[repr(transparent)] over u32 (itself Pod).

Level of scrutiny

High. Although each individual edit is mechanical and the compiler enforces the crossings, the change spans the lockfile's on-disk format: Tree::to_external/to_tree now go through .get()/DependencyID::new() around the same u32::to_ne_bytes round-trip, Buffers::load's legacy path explicitly reinterprets stored DependencyID bytes as PackageID, and bytemuck::cast_slice_mut is used to sort id buffers. A mistake in any of these would silently corrupt user lockfiles rather than fail to compile. I checked that repr(transparent) preserves size/alignment (pinned in padding_checker.rs), that ROOT_DEP_ID's value is unchanged, that Default still yields 0, and that Debug/Display forward to the inner u32 so bun pm ls and the debug-JSON output are unaffected.

Other factors

The PR is well-tested (~2,150 install tests passed locally against a debug build; CI passed 177/179 with the two remaining jobs being darwin agents that never picked up work). A maintainer (alii) has already engaged asking for a conflict resolution, which was done via merge commit 96279bab. No bugs were surfaced by the multi-agent review. However, per the approval guidelines a 65-file change to the package manager's core buffers and serialization is exactly the class of change that warrants a human reviewer's sign-off rather than an automated approval, so I'm deferring.

@alii alii left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The retype itself checks out: every non-mechanical hunk keeps the same value, no try_from/as drift, on-disk layout pinned, hooks crossing updated. One change requested below on the Default derive, and a rebase note: main has already dropped the two stale baseline entries, so the "regenerate the baseline" commit becomes empty on rebase and can go.

Not for this PR, just noting: x[id.index()] at ~600 subscript sites is still untyped, so the newtype guards params and fields but not the buffer indexing itself. An id-keyed slice type with Index<PackageID> would close that; worth a follow-up, not worth growing this diff.

Comment thread src/install_types/resolver_hooks.rs Outdated
@alii

alii commented Aug 15, 2026

Copy link
Copy Markdown
Member

Correction to my review: do the typed indexing in this PR after all, no follow-up. Add an id-keyed slice/vec type (Index / Index, repr(transparent) over the slice so the bun.lockb bytemuck casts and index_sort keep working) and use it for the parallel dependency buffers and the package columns, so x[id.index()] goes away and .index() is left only where a real usize is needed. Same PR, on top of the Default change.

IdSlice/IdVec (bun_collections) are slices and vectors subscripted with an
id newtype; they deref to the plain slice for everything else, and a type
with its own Index impl rejects usize subscripts, so raw positions go
through raw()/raw_mut(). The Package columns are declared indexed by
PackageID through multi_array_columns!, and buffers.dependencies /
buffers.resolutions are IdVec<DependencyID, _>. Every x[id.index()] in
install and the pm commands becomes x[id]; Lockfile::package(id) gathers a
row; the remaining .index() calls feed bitsets and packages.len() checks.
@robobun
robobun force-pushed the farm/bc4bffa4/install-id-newtypes branch from e122818 to bf1a583 Compare August 16, 2026 01:41
@robobun robobun changed the title install: make PackageID and DependencyID newtypes instead of u32 aliases install: make PackageID and DependencyID newtypes and index the lockfile buffers by them Aug 16, 2026
@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Both review items are in, plus the typed indexing from the follow-up comment; the description now describes the branch as it stands.

  • Rebased onto current main as you suggested, so the "regenerate the baseline" commit is gone and the baseline diff is the one Tree.rs line. The rebase replaced the two merge commits on the branch (yours included); I checked that git diff between your merge and the rebased equivalent commit has the same patch-id as main's own movement over that window, so nothing from it was lost. Five commits now: newtypes, baseline line, pm ls loop, Default = INVALID, typed indexing.
  • Typed indexing (bf1a583): IdSlice<I, T> / IdVec<I, T> in bun_collections, repr(transparent) over the slice so the bun.lockb writer and the index_sort / bytemuck casts keep working. They deref to [T], but since the wrapper has its own Index impl the compiler does not fall through to slice indexing, so col[0], col[i] and buf[a..b] are all compile errors and raw positions have to go through raw() / raw_mut() (12 sites, all sub-ranges or the sort cast). The Package columns are declared indexed by PackageID through multi_array_columns! (so items_*() and split_mut return IdSlice<PackageID, _>), buffers.dependencies / buffers.resolutions are IdVec<DependencyID, _>, and the per-package / per-dependency scratch vectors, PackageInstaller's column back-refs, reachable::Walk, the dedupe pass and the resolver hooks follow. There is no x[id.index()] left in src/install or the pm commands; what remains of .index() feeds bitsets and packages.len() checks, plus the one packages.get() inside the new Lockfile::package(id).
  • Same verification as before on the final tree: workspace check, clippy on the eight affected crates (including the other multi_array_columns! users), fmt, mordant clean against the baseline, and about 2,450 install tests on a debug build with the same pre-existing failure set as before any of this (listed in the description). The two migration tests near the 5 s limit run in the same time as before the wrappers, so the extra layer is not measurable even unoptimized.

Checks that PackageID / DependencyID are newtypes, that the package columns
and the dependency buffers are declared id-indexed, and that no
buffer[id.index()] or buffer.get(id.index()) subscript is left in the
install crate or the pm commands (two documented exceptions).
Comment thread src/install/PackageInstaller.rs Outdated
Comment thread src/install/yarn.rs
…ef'd slices

With the buffers as IdVec, .as_mut_ptr() resolved through Deref to the
slice's, whose pointer only covers len (0 here) while the migration fills
the reserved capacity through it; raw_mut().as_mut_ptr() is Vec's pointer
again, as the set_len below already does.

@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 yarn.rs provenance fix in e97a91b looks correct — raw_mut().as_mut_ptr() now reaches Vec::as_mut_ptr so the pointer covers the reserved capacity, and the IdVec doc comment now warns about this pitfall. This run found no further issues.

Given the size (71 files across the install/lockfile core, new unsafe in IdSlice::from_raw{,_mut}, and the bun.lockb on-disk layout being held byte-identical only by repr(transparent) + the padding_checker pins), a final human look from alii — who has been driving the design here — is still the right call before merge.

Extended reasoning...

Overview

This PR converts PackageID and DependencyID from u32 type aliases to #[repr(transparent)] newtypes and threads them through the entire install subsystem: 71 files including the lockfile core (lockfile.rs, Buffers.rs, Tree.rs, Package.rs, bun.lock.rs, bun.lockb.rs), both installers (hoisted/isolated), the package manager, migrators (npm/pnpm/yarn), dedupe, audit, prune, and every bun pm command. It adds IdSlice/IdVec in bun_collections and an indexed by clause to multi_array_columns! so the ~600 buffer[id.index()] sites become buffer[id] and cross-type indexing is a compile error.

Changes since my last review

My 02:27 inline comment flagged that yarn.rs:794-795's .as_mut_ptr() now resolved through DerefMut → [T]::as_mut_ptr (provenance limited to len, not capacity) instead of Vec::as_mut_ptr. Commit e97a91b fixes this: both sites now use .raw_mut().as_mut_ptr(), the SAFETY comment explains why, and the IdVec doc comment (id_slice.rs:143-146) now documents the hazard for future callers. I grepped the rest of the install crate for other as_mut_ptr on the retyped IdVec fields and found none — the remaining .as_mut_ptr() calls in yarn.rs are on the local &mut [T] slices (dependencies_buf, resolutions_buf) which already have full-range provenance from ffi::slice_mut. The three earlier stale-RawSlice comments and the Default = INVALID review item were already confirmed fixed.

Security risks

None identified. The change is type-level; no new external input parsing, no auth/permission surface. The new unsafe in IdSlice::from_raw{,_mut} is a repr(transparent) pointer cast with a SAFETY comment stating the layout-equality justification, which is the standard pattern.

Level of scrutiny

High. The bun.lockb binary format serializes resolutions, hoisted_dependencies, and the 20-byte Tree records as raw bytes; correctness depends on repr(transparent) + bytemuck::Pod keeping the wire format identical (pinned in padding_checker.rs and covered by the lockfile round-trip suites). Default changed from 0 (root) to INVALID, which alii audited but is a semantic shift. The multi_array_columns! macro grew three new arms and helper macros. This is well beyond the "simple, mechanical, obvious" bar for auto-approval.

Other factors

alii has been actively reviewing and shaping this PR (requested Default=INVALID, requested the typed-indexing be done here rather than a follow-up) but has not yet approved. The bug-hunting system found nothing this run. The author ran ~2,450 install tests locally with the same pre-existing failure set before and after. Deferring so alii can give the final sign-off on a change they have been directing.

…lumn traits

These run under the cargo miri test job, so the from_raw / from_raw_mut
casts and the IdSlice-wrapped split_mut columns get interpreted there
rather than only being compiled as part of the install crate.
@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Nothing outstanding from the review passes; the three inline threads are answered and resolved. One more follow-up pushed (72b03b5): the two residual-risk points the last pass named (the IdSlice::from_raw / from_raw_mut casts, and the indexed by columns built in split_mut) now have unit tests in bun_collections, which is in the crate set the cargo miri test job runs, so both are interpreted under Tree Borrows in CI instead of only being compiled through the install crate. Passes locally under cargo test and cargo miri test (42 tests, 7 new). The on-disk layout is covered as before by the padding_checker pins and the bun.lockb round-trip suites. Over to @alii for the re-review.

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

Re-reviewed after e97a91b / a13e861 / 72b03b5 — both earlier findings are addressed (yarn.rs now takes the capacity pointers via raw_mut().as_mut_ptr() with an updated SAFETY note; the stale RawSlice comments are gone) and this pass found nothing further. Given the scope — 71 files across the lockfile buffers, package columns, hoisting, and both migrators — a final look from alii on the typed-indexing commit would still be worthwhile.

Checked this run: the IdSlice repr(transparent) casts and the multi_array_columns! indexed by expansion; that Default for both ids is INVALID and the remaining get_or_put fills overwrite the slot on every path; the bun.lockb layout pins in padding_checker.rs and Tree::EXTERNAL_SIZE; and swept src/install for other .as_mut_ptr() / .as_ptr() on IdVec fields — the two in yarn.rs were the only ones.

Extended reasoning...

Overview

This PR converts PackageID and DependencyID from type X = u32 aliases into #[repr(transparent)] newtypes and adds IdSlice<I, T> / IdVec<I, T> so the lockfile's parallel buffers and MultiArrayList<Package> columns are subscripted only by their own id type. It touches 71 files (~490 KB diff): the new collection types and the multi_array_columns! indexed by clause in src/collections/, the id newtypes in src/install_types/resolver_hooks.rs, and every consumer across src/install/, the resolver hooks, and the bun pm CLI commands. On-disk layout is preserved via repr(transparent) + bytemuck::Pod and pinned in padding_checker.rs.

Security risks

None identified. This is a type-tightening refactor of internal indexing; no new user input parsing, no auth/crypto/permissions surface. The one memory-safety regression it introduced (slice-provenance as_mut_ptr() in the yarn migrator) was caught in the previous pass and is fixed in e97a91b.

Level of scrutiny

High. The change is intended to be behavior-preserving, but it rewrites indexing across the lockfile serializer, both migrators (npm/pnpm/yarn), the hoist tree builder, the isolated installer, dedupe, audit-fix, and every bun pm command — all production-critical paths where an off-by-one or a wrong-buffer subscript would corrupt a lockfile or misinstall. The IdVec deref-to-slice design also silently changes method resolution for anything not explicitly forwarded (which is exactly how the yarn.rs bug arose). alii has been actively reviewing and drove two of the design decisions (Default = INVALID, typed indexing in this PR rather than a follow-up); a final sign-off from them on the current head is appropriate before merge.

Other factors

  • Both prior inline findings from this bot are resolved and verified in the tree at HEAD (72b03b5): src/install/yarn.rs:796-797 now goes through raw_mut().as_mut_ptr() with a corrected SAFETY comment, and no RawSlice<Resolution> string remains in PackageInstaller.rs.
  • The IdVec doc comment now explicitly warns about the as_ptr/as_mut_ptr deref hazard, and a grep of src/install confirms no other IdVec field has .as_mut_ptr() / .as_ptr() called on it.
  • 72b03b5 adds unit tests for IdSlice/IdVec and both multi_array_columns! forms; test/internal/source-lints/install-id-indexing.test.ts guards against regressions.
  • The bug-hunting system found nothing on this head.

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Nothing further to change from this pass. For the record on 72b03b5: the cargo miri test job (which now includes the IdSlice / IdVec and indexed by tests), cargo clippy, mordant, and the source lints are all green on this head; the Buildkite run is in progress, and the previous head's run finished with no failures. No open review threads. Waiting on @alii.

@alii

alii commented Aug 16, 2026

Copy link
Copy Markdown
Member

This has gotten too big and too laser focused on a particular solution that I think we've lost the point of the original change.

Could we instead make a larger refactor elsewhere that eliminates this problem entirely? What other options are there? Going to close this one for now. Feel free to open a followup if there's a better design you can find please @robobun.

@alii alii closed this Aug 16, 2026
@alii
alii deleted the farm/bc4bffa4/install-id-newtypes branch August 16, 2026 03:16
@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Agreed on closing it. The size was structural rather than a choice of container: on main, 58 files reach straight into buffers.dependencies / buffers.resolutions or the package columns (about 900 sites, 39 of those files read-only), and Rust has no strong typedef, so anything the compiler enforces has to visit every site that casts an id. The newtype-only commit was 63 files on its own; the typed indexing was the same 61 files a second time.

Options I can see, honestly weighed:

  1. Fix the labels, keep the aliases. ROOT_DEP_ID derived from the dependency sentinel (the lint finding), plus the other mislabeled declarations the newtypes turned up and which are still on main: PackageManager::FailFn's id parameter, PendingResolution::resolve_id, tree::Builder::resolution_lists and hoist_dependency's range (typed as dependency-id slices, fed package-id slices), the four Package.resolutions constructions in yarn.rs, Tree::EXTERNAL_SIZE, the INVALID_PACKAGE_ID dependency sentinels in tree_printer.rs and the resolver's PendingResolution, TaskCallbackContext::root_request_id, loaded_package_count. About 8 files and a few dozen lines, all value-identical, and the baseline entry goes. It enforces nothing; the next mixup compiles just like these did.

  2. Shrink the surface first, type it last. Most of the 39 read-only files hand-roll the same loop (a package's dependency range, then dependencies[dep_id] and resolutions[dep_id]). A small read API on Lockfile (an edge iterator yielding the dependency id, the Dependency, and its resolution, with a variant that takes a candidate resolutions buffer for dedupe, plus the handful of column getters the CLI commands use) lets those files stop holding the buffers at all, in independent small PRs with no type changes and each useful on its own. After that, ids are spelled mostly in the 19 files that build the buffers (lockfile core, resolution, the installers, the migrators), and a newtype step at the end is confined to them. More total churn than this PR, spread over several reviewable PRs; the hot paths (PackageInstaller, hoisting, the tree builder) would keep raw access, so they stay in the final step either way.

  3. The same newtypes, sliced. Introduce them under new names beside the aliases and migrate module by module with conversions at the seams. Reviewable in pieces, but the intermediate states have two spellings of every id, and it ends where the first half of this PR ended. I would not pick it over 2.

  4. What does not work. Removing one of the id spaces: dependency ids are edge indices, persisted in Tree, hoisted_dependencies and bun.lockb, and they key the resolution queue (tree::Id is a third alias of the same kind). Leaning on the lint: interchangeable_aliases found one of the roughly twenty mislabels above, because it only sees alias-to-alias flows, not the casts the indexing goes through.

My suggestion would be 1 now, since it is small and closes out the finding, and 2 as the direction if eliminating the class is worth it. Happy to open either; say which.

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Measured the options rather than estimating them. Every state below is built from the same merge base (a42889a), so the deltas are only the change in question.

How it was measured

  • Source: git diff --numstat of the real diff for each option. Options 1 and 2 were prototyped for this (branches linked below); the newtype numbers are the closed branch's own commits.
  • Non-LTO binary: cargo build --release with lto=off, codegen-units=1, .text of libbun_install.rlib summed with llvm-size (2,740,678 bytes at the base). bun_install_types, bun_resolver, bun_collections, bun_ast, bun_js_parser and bun_bundler were measured too and are byte-identical in every state, so only bun_install is shown; bun_runtime (the bun pm commands, one loop in the option 2 slice) was not measured on its own and is only covered by the LTO column.
  • LTO binary: cargo build --release -p bun_bin with the workspace profile (lto = "fat", codegen-units = 1), .text of libbun_rust.a (23,066,021 bytes at the base). The shipped build goes through cross-language ThinLTO instead, so read this column for magnitude, not exact bytes.
  • Consumer/core split: on main, 58 files reach into buffers.dependencies / buffers.resolutions or the package columns directly; 26 of them only read (CLI commands, printers, reachability, dedupe, prune, audit, security scanner, ...), the rest build or mutate the buffers (lockfile core, resolution, the two installers, the migrators).

Results

option source diff bun_install .text, non-LTO whole Rust .text, fat LTO what it fixes
1. relabel, keep the aliases (branch) 9 files, +26 / -28 (22 lines are the relabels, the rest is rustfmt reflow and the baseline line) +0 (identical code) +0 the lint finding plus the other ~20 mislabeled declarations; regenerating the baseline drops the Tree.rs entry and adds nothing (verified). Enforces nothing.
2. read API on Lockfile, consumers stop walking the buffers (prototype branch: edges() / edges_in() plus 12 of the ~42 loops) measured slice: 5 files, +164 / -176 (69 of the + is the API). Whole program, extrapolated: ~27 files, roughly +450 / -600 +627 for the slice (~+2 KB for all loops) +605 for the slice consumers get shorter (net negative) and stop handling dependency ids by hand. Enforces nothing by itself.
3. newtypes (the closed branch's first commit, however it is sliced into PRs) 63 files, +1,210 / -1,072, plus a bytemuck dependency in install_types +9,565 (+0.35%) -95 the only option the compiler enforces.
closed branch (3 plus typed indexing) 71 files, +2,374 / -1,673 +9,318 (+0.34%) -12,027 (-0.05%) enforces the indexing too.

Reading the numbers

  • Binary size does not separate the options. The largest movement anywhere is 0.05% of the Rust text, and the LTO swings are mostly the usual whole-module inlining churn: in the closed branch, bun_install's own functions move +1.6 KB under LTO while the -12 KB comes from renamed generic instantiations and inlining shifts elsewhere in the 23 MB module. The one structural cost of newtypes is visible in the non-LTO column: generic code now instantiated per id type next to the u32 instantiations that remain (ArrayHashMap<PackageID, ()> and <DependencyID, ()> where one <u32, ()> served before, [PackageID]::sort_unstable ~3.2 KB, Vec<PackageID>::from_elem, eight 100-byte RawVec<(.., PackageID)>::grow_ones), about +4.5 KB net; the other ~5 KB is inlining (for example the three write_array instantiations folding into save_to_disk). All of it is noise at the binary level.
  • Source size is the real axis, and it is 54 lines vs roughly a thousand vs roughly 2,300.
  • Option 2 is not a cheaper road to option 3. Splitting the newtype commit by file: the 25 consumer files account for +335 / -346 of it, the 38 core files for +875 / -726. So even with every consumer moved onto a read API first, making the ids distinct types is still a ~40-file, ~1,600-line change in the code that builds the buffers. Option 2 stands or falls as a readability cleanup on its own (it is net negative in lines and costs ~2 KB), not as a way to shrink the type change.
  • Nothing eliminates the two id spaces: dependency ids are persisted in Tree, hoisted_dependencies and bun.lockb, and tree::Id is a third alias of the same kind. The lint also cannot substitute for types: it saw one of the ~20 mislabels, because the rest flow through as usize casts it does not track.

Conclusion: by the stated criterion, option 1, and it is not close: it is the only option whose cost is small on either axis, and it closes out the finding this started from (branch above, ready to open as a PR). Option 2 is worth doing only if the shorter consumers are wanted for their own sake. If the class of bug is ever worth eliminating, that costs ~63 files whichever way it is sliced; the closed branch's first commit is that change and can be reused. Say the word and I will open option 1.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants