Compare commits

...

3 Commits

Author SHA1 Message Date
Claude Bot
dbeecb97f9 fix(test): add timeout and update snapshot for yarn.lock migration tests
- Add setDefaultTimeout(5 minutes) to yarn-lock-migration.test.ts to
  prevent tests from timing out when they need network access
- Update snapshot to reflect the new behavior where unresolved required
  peer dependencies are added to optionalPeers in the lockfile

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 00:47:21 +00:00
Jarred Sumner
529fda21d2 Merge branch 'main' into claude/fix-unresolved-peer-deps-lockfile 2026-01-14 13:26:18 -08:00
Claude Bot
20f5f45653 fix(install): add unresolved peer deps to optionalPeers in lockfile
When writing a lockfile, unresolved required peer dependencies are now
added to `optionalPeers`. This prevents `bun pm ls` and other commands
from failing with "InvalidPackageInfo" when loading a lockfile that has
peer dependencies that couldn't be resolved.

Previously, unresolved peer deps were only added to `optionalPeers` when
migrating from a binary lockfile. Now they're always added, using the
resolution buffer to check if a peer dep is resolved.

Fixes #26046

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 00:06:06 +00:00
5 changed files with 188 additions and 72 deletions

View File

@@ -344,7 +344,7 @@ pub fn loadFromDir(
defer writer_allocating.deinit();
const writer = &writer_allocating.writer;
TextLockfile.Stringifier.saveFromBinary(allocator, result.ok.lockfile, &result, &manager.?.options, writer) catch |err| {
TextLockfile.Stringifier.saveFromBinary(allocator, result.ok.lockfile, &manager.?.options, writer) catch |err| {
Output.panic("failed to convert binary lockfile to text lockfile: {s}", .{@errorName(err)});
};
@@ -1243,7 +1243,7 @@ pub fn saveToDisk(this: *Lockfile, load_result: *const LoadResult, options: *con
defer writer_allocating.deinit();
const writer = &writer_allocating.writer;
TextLockfile.Stringifier.saveFromBinary(bun.default_allocator, this, load_result, options, writer) catch |err| switch (err) {
TextLockfile.Stringifier.saveFromBinary(bun.default_allocator, this, options, writer) catch |err| switch (err) {
error.WriteFailed => bun.outOfMemory(),
};

View File

@@ -27,10 +27,10 @@ pub const Stringifier = struct {
// _ = this;
// }
pub fn saveFromBinary(allocator: std.mem.Allocator, lockfile: *BinaryLockfile, load_result: *const LoadResult, options: *const PackageManager.Options, writer: *std.Io.Writer) std.Io.Writer.Error!void {
return bun.handleOom(saveFromBinary_inner(allocator, lockfile, load_result, options, writer));
pub fn saveFromBinary(allocator: std.mem.Allocator, lockfile: *BinaryLockfile, options: *const PackageManager.Options, writer: *std.Io.Writer) std.Io.Writer.Error!void {
return bun.handleOom(saveFromBinary_inner(allocator, lockfile, options, writer));
}
pub fn saveFromBinary_inner(allocator: std.mem.Allocator, lockfile: *BinaryLockfile, load_result: *const LoadResult, options: *const PackageManager.Options, writer: *std.Io.Writer) !void {
pub fn saveFromBinary_inner(allocator: std.mem.Allocator, lockfile: *BinaryLockfile, options: *const PackageManager.Options, writer: *std.Io.Writer) !void {
const buf = lockfile.buffers.string_bytes.items;
const extern_strings = lockfile.buffers.extern_strings.items;
const deps_buf = lockfile.buffers.dependencies.items;
@@ -60,32 +60,8 @@ pub const Stringifier = struct {
var optional_peers_buf = std.array_list.Managed(String).init(allocator);
defer optional_peers_buf.deinit();
var pkg_map = PkgMap(void).init(allocator);
defer pkg_map.deinit();
var pkgs_iter = BinaryLockfile.Tree.Iterator(.pkg_path).init(lockfile);
var path_buf: bun.PathBuffer = undefined;
// if we loaded from a binary lockfile and we're migrating it to a text lockfile, ensure
// peer dependencies have resolutions, and mark them optional if they don't
if (load_result.loadedFromBinaryLockfile()) {
while (pkgs_iter.next({})) |node| {
for (node.dependencies) |dep_id| {
const dep = deps_buf[dep_id];
// clobber, there isn't data
try pkg_map.put(try std.fmt.allocPrint(allocator, "{s}{s}{s}", .{
node.relative_path,
if (node.depth == 0) "" else "/",
dep.name.slice(buf),
}), {});
}
}
pkgs_iter.reset();
}
var _indent: u32 = 0;
const indent = &_indent;
try writer.writeAll("{\n");
@@ -113,11 +89,9 @@ pub const Stringifier = struct {
buf,
extern_strings,
deps_buf,
resolution_buf,
lockfile.workspace_versions,
&optional_peers_buf,
&pkg_map,
"",
&path_buf,
);
var workspace_sort_buf: std.ArrayListUnmanaged(PackageID) = .{};
@@ -164,11 +138,9 @@ pub const Stringifier = struct {
buf,
extern_strings,
deps_buf,
resolution_buf,
lockfile.workspace_versions,
&optional_peers_buf,
&pkg_map,
pkg_names[workspace_pkg_id].slice(buf),
&path_buf,
);
}
}
@@ -502,14 +474,12 @@ pub const Stringifier = struct {
dep.behavior,
deps_buf,
pkg_deps_sort_buf.items,
resolution_buf,
&pkg_meta,
&pkg_bin,
buf,
&optional_peers_buf,
extern_strings,
&pkg_map,
relative_path,
&path_buf,
);
try writer.writeByte(']');
@@ -525,14 +495,12 @@ pub const Stringifier = struct {
dep.behavior,
deps_buf,
pkg_deps_sort_buf.items,
resolution_buf,
&pkg_meta,
&pkg_bin,
buf,
&optional_peers_buf,
extern_strings,
&pkg_map,
relative_path,
&path_buf,
);
try writer.writeByte(']');
@@ -548,14 +516,12 @@ pub const Stringifier = struct {
dep.behavior,
deps_buf,
pkg_deps_sort_buf.items,
resolution_buf,
&pkg_meta,
&pkg_bin,
buf,
&optional_peers_buf,
extern_strings,
&pkg_map,
relative_path,
&path_buf,
);
try writer.writeByte(']');
@@ -571,14 +537,12 @@ pub const Stringifier = struct {
dep.behavior,
deps_buf,
pkg_deps_sort_buf.items,
resolution_buf,
&pkg_meta,
&pkg_bin,
buf,
&optional_peers_buf,
extern_strings,
&pkg_map,
relative_path,
&path_buf,
);
try writer.writeByte(']');
@@ -602,14 +566,12 @@ pub const Stringifier = struct {
dep.behavior,
deps_buf,
pkg_deps_sort_buf.items,
resolution_buf,
&pkg_meta,
&pkg_bin,
buf,
&optional_peers_buf,
extern_strings,
&pkg_map,
relative_path,
&path_buf,
);
try writer.print(", \"{f}\"]", .{
@@ -634,14 +596,12 @@ pub const Stringifier = struct {
dep.behavior,
deps_buf,
pkg_deps_sort_buf.items,
resolution_buf,
&pkg_meta,
&pkg_bin,
buf,
&optional_peers_buf,
extern_strings,
&pkg_map,
relative_path,
&path_buf,
);
try writer.print(", {f}]", .{
@@ -670,14 +630,12 @@ pub const Stringifier = struct {
dep_behavior: Dependency.Behavior,
deps_buf: []const Dependency,
pkg_dep_ids: []const DependencyID,
resolution_buf: []const PackageID,
meta: *const Meta,
bin: *const Install.Bin,
buf: string,
optional_peers_buf: *std.array_list.Managed(String),
extern_strings: []const ExternalString,
pkg_map: *const PkgMap(void),
relative_path: string,
path_buf: []u8,
) error{ OutOfMemory, WriteFailed }!void {
defer optional_peers_buf.clearRetainingCapacity();
@@ -715,10 +673,10 @@ pub const Stringifier = struct {
bun.fmt.formatJSONStringUTF8(dep.version.literal.slice(buf), .{}),
});
if (dep.behavior.peer and !dep.behavior.optional and pkg_map.map.count() > 0) {
pkg_map.findResolution(relative_path, dep, buf, path_buf) catch {
try optional_peers_buf.append(dep.name);
};
// If a required peer dependency is not resolved, add it to optionalPeers
// to prevent lockfile loading errors when the peer is not installed.
if (dep.behavior.peer and !dep.behavior.optional and resolution_buf[dep_id] == invalid_package_id) {
try optional_peers_buf.append(dep.name);
}
}
@@ -816,11 +774,9 @@ pub const Stringifier = struct {
buf: string,
extern_strings: []const ExternalString,
deps_buf: []const Dependency,
resolution_buf: []const PackageID,
workspace_versions: BinaryLockfile.VersionHashMap,
optional_peers_buf: *std.array_list.Managed(String),
pkg_map: *const PkgMap(void),
relative_path: string,
path_buf: []u8,
) error{ OutOfMemory, WriteFailed }!void {
defer optional_peers_buf.clearRetainingCapacity();
// any - have any properties been written
@@ -878,7 +834,10 @@ pub const Stringifier = struct {
const group_name, const group_behavior = group;
var first = true;
for (pkg_deps[pkg_id].get(deps_buf)) |*dep| {
const dep_slice = pkg_deps[pkg_id];
for (dep_slice.begin()..dep_slice.end()) |_dep_id| {
const dep_id: DependencyID = @intCast(_dep_id);
const dep = &deps_buf[dep_id];
if (!dep.behavior.includes(group_behavior)) continue;
if (dep.behavior.isOptionalPeer()) {
@@ -914,12 +873,10 @@ pub const Stringifier = struct {
bun.fmt.formatJSONStringUTF8(version, .{}),
});
if (dep.behavior.peer and !dep.behavior.optional and pkg_map.map.count() > 0) {
pkg_map.findResolution(relative_path, dep, buf, path_buf) catch |err| {
if (err == error.Unresolvable) {
try optional_peers_buf.append(dep.name);
}
};
// If a required peer dependency is not resolved, add it to optionalPeers
// to prevent lockfile loading errors when the peer is not installed.
if (dep.behavior.peer and !dep.behavior.optional and resolution_buf[dep_id] == invalid_package_id) {
try optional_peers_buf.append(dep.name);
}
}
@@ -2253,7 +2210,6 @@ const invalid_package_id = Install.invalid_package_id;
const BinaryLockfile = bun.install.Lockfile;
const DependencySlice = BinaryLockfile.DependencySlice;
const LoadResult = BinaryLockfile.LoadResult;
const Meta = BinaryLockfile.Package.Meta;
const Npm = Install.Npm;

View File

@@ -288,6 +288,9 @@ exports[`yarn.lock migration basic migration with realistic complex yarn.lock: c
"peerDependencies": {
"react": ">=16.8.0",
},
"optionalPeers": [
"react",
],
},
},
"packages": {

View File

@@ -1,8 +1,12 @@
import { describe, expect, test } from "bun:test";
import { beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
import fs from "fs";
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
import { join } from "path";
beforeAll(() => {
setDefaultTimeout(1000 * 60 * 5);
});
describe("yarn.lock migration basic", () => {
test("simple yarn.lock migration produces correct bun.lock", async () => {
const tempDir = tempDirWithFiles("yarn-migration-simple", {

View File

@@ -0,0 +1,153 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// Test for GitHub issue #26046
// `bun pm ls` fails with `error: Error loading lockfile: InvalidPackageInfo`
// when a package has required (non-optional) peer dependencies that are not installed.
test("lockfile with optionalPeers loads successfully for bun pm ls", async () => {
// This test verifies that when a lockfile properly includes unresolved peer deps
// in optionalPeers (which our fix ensures during lockfile writing), the lockfile
// can be loaded without errors by commands like `bun pm ls`.
// Create a lockfile that simulates the FIXED behavior:
// - Package has a peer dependency that is NOT resolved (not installed)
// - The peer dependency IS in optionalPeers (because our fix adds it there)
const lockfile = {
lockfileVersion: 1,
configVersion: 1,
workspaces: {
"": {
name: "test-issue-26046",
devDependencies: {
"test-pkg": "1.0.0",
},
},
},
packages: {
"test-pkg": [
"test-pkg@1.0.0",
"",
{
peerDependencies: {
"unresolved-peer": "*",
},
// Key: the unresolved peer IS in optionalPeers
// Our fix ensures this happens during lockfile writing
optionalPeers: ["unresolved-peer"],
},
"sha512-test==",
],
},
};
using dir = tempDir("issue-26046", {
"package.json": JSON.stringify({
name: "test-issue-26046",
devDependencies: {
"test-pkg": "1.0.0",
},
}),
"bun.lock": JSON.stringify(lockfile, null, 2),
// Create a fake node_modules structure so bun pm ls doesn't try to install
"node_modules/test-pkg/package.json": JSON.stringify({
name: "test-pkg",
version: "1.0.0",
peerDependencies: {
"unresolved-peer": "*",
},
}),
});
// Run bun pm ls - with the fix, this should work because the unresolved
// peer dep is properly marked as optional in the lockfile
await using lsProc = Bun.spawn({
cmd: [bunExe(), "pm", "ls"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [lsStdout, lsStderr, lsExitCode] = await Promise.all([
lsProc.stdout.text(),
lsProc.stderr.text(),
lsProc.exited,
]);
// Should not have InvalidPackageInfo error
expect(lsStderr).not.toContain("InvalidPackageInfo");
expect(lsStdout).toContain("test-pkg");
expect(lsExitCode).toBe(0);
});
test("lockfile WITHOUT optionalPeers fails for unresolved required peer deps", async () => {
// This test documents the bug that existed before the fix:
// When a lockfile has a peer dep that's NOT in optionalPeers and NOT installed,
// loading the lockfile fails with InvalidPackageInfo.
//
// NOTE: This test shows the BROKEN behavior that users experienced.
// The fix prevents new lockfiles from being written in this broken state.
// Create a lockfile that simulates the BROKEN (pre-fix) behavior:
// - Package has a required peer dependency that is NOT resolved
// - The peer dependency is NOT in optionalPeers (the bug)
const lockfile = {
lockfileVersion: 1,
configVersion: 1,
workspaces: {
"": {
name: "test-issue-26046",
devDependencies: {
"test-pkg": "1.0.0",
},
},
},
packages: {
"test-pkg": [
"test-pkg@1.0.0",
"",
{
peerDependencies: {
"unresolved-peer": "*",
},
// Bug: the unresolved peer is NOT in optionalPeers
// This causes InvalidPackageInfo when loading
},
"sha512-test==",
],
},
};
using dir = tempDir("issue-26046-broken", {
"package.json": JSON.stringify({
name: "test-issue-26046",
devDependencies: {
"test-pkg": "1.0.0",
},
}),
"bun.lock": JSON.stringify(lockfile, null, 2),
"node_modules/test-pkg/package.json": JSON.stringify({
name: "test-pkg",
version: "1.0.0",
peerDependencies: {
"unresolved-peer": "*",
},
}),
});
// Run bun pm ls - this should fail with InvalidPackageInfo
await using lsProc = Bun.spawn({
cmd: [bunExe(), "pm", "ls"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [_, lsStderr, lsExitCode] = await Promise.all([lsProc.stdout.text(), lsProc.stderr.text(), lsProc.exited]);
// This shows the broken behavior: lockfile loading fails
expect(lsStderr).toContain("InvalidPackageInfo");
expect(lsExitCode).toBe(1);
});