Skip to content
64 changes: 64 additions & 0 deletions src/install/lockfile/Package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1539,6 +1539,63 @@ impl Diff {
}
}

#[cold]
#[inline(never)]
fn warn_linked_peer_dependencies(source: &bun_ast::Source, json: &Expr, bump: &bun_alloc::Arena) {
let Some(peer_deps) = json.as_property(b"peerDependencies") else {
return;
};
if peer_deps.expr.property_count() == 0 {
return;
}

let peer_meta = json.as_property(b"peerDependenciesMeta");
let linked_dir = source.path.name().dir;

let mut unresolved: Vec<(&[u8], &[u8], bool)> = Vec::new();
peer_deps.expr.for_each_property(|key, _loc, value| {
let installed = resolve_path::join_abs_string_z::<path::platform::Auto>(
linked_dir,
&[b"node_modules", key, b"package.json"],
);
if bun_sys::exists_z(installed) {
return;
}
Comment thread
claude[bot] marked this conversation as resolved.
let ver = value.as_utf8(bump).unwrap_or(b"");
let is_optional = peer_meta
.as_ref()
.and_then(|m| m.expr.as_property(key))
.and_then(|m| m.expr.as_property(b"optional"))
.map(|o| matches!(&o.expr.data, ExprData::EBoolean(b) if b.value))
.unwrap_or(false);
unresolved.push((bump.alloc_slice_copy(key), ver, is_optional));
});
if unresolved.is_empty() {
return;
}

let name = json
.as_property(b"name")
.and_then(|q| q.expr.as_utf8(bump))
.unwrap_or(b"");
bun_core::warn!(
"Linked package <b>\"{}\"<r> declares peerDependencies that may not resolve from this project:",
bstr::BStr::new(name),
);
for (key, ver, is_optional) in &unresolved {
bun_core::pretty_errorln!(
" <d>-<r> {}<d>@{}{}<r>",
bstr::BStr::new(key),
bstr::BStr::new(ver),
if *is_optional { " (optional)" } else { "" },
);
}
bun_core::pretty_errorln!(
" Linked packages resolve modules from their real location on disk. Install these peers in the linked package's own node_modules.",
);
Output::flush();
}

impl Package<u64> {
pub fn hash(name: &[u8], version: SemverVersion) -> u64 {
let mut hasher = bun_wyhash::Wyhash::init(0);
Expand Down Expand Up @@ -2165,6 +2222,13 @@ impl Package<u64> {
out
};

// Symlinked packages realpath before node_modules lookup, so peers here are invisible; warn like pnpm.
if FEATURES == Features::LINK
&& pm.options.log_level != crate::package_manager::LogLevel::Silent
{
warn_linked_peer_dependencies(source, &json, &bump);
}

let mut workspace_names = workspace_map::WorkspaceMap::init();
// defer workspace_names.deinit(); — Drop handles it

Expand Down
110 changes: 109 additions & 1 deletion test/cli/install/bun-link.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { file, spawn } from "bun";
import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "bun:test";
import { access, mkdir, writeFile } from "fs/promises";
import { access, mkdir, rm, writeFile } from "fs/promises";
import {
bunExe,
bunEnv as env,
Expand Down Expand Up @@ -472,3 +472,111 @@
// This should fail with a non-zero exit code.
expect(await exited4).toBe(1);
});

// https://github.com/oven-sh/bun/issues/13676
it("should warn when linked package has peerDependencies", async () => {
const link_name = basename(link_dir).slice("bun-link.".length);
await writeFile(
join(link_dir, "package.json"),
JSON.stringify({
name: link_name,
version: "0.0.1",
peerDependencies: {
"peer-one": "^1.0.0",
"peer-two": "*",
"peer-three": "^2.0.0",
},
peerDependenciesMeta: {
"peer-two": { optional: true },
},
}),
);
// peer-three is already installed in the linked package's own node_modules,
// so it resolves from there and should not be listed in the warning.
await mkdir(join(link_dir, "node_modules", "peer-three"), { recursive: true });
await writeFile(
join(link_dir, "node_modules", "peer-three", "package.json"),
JSON.stringify({ name: "peer-three", version: "2.0.0" }),
);
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({
name: "consumer",
version: "0.0.2",
}),
);

async function run(cmd: string[], cwd: string) {
const proc = spawn({ cmd, cwd, stdout: "pipe", stdin: "pipe", stderr: "pipe", env });
const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { out, err: stderrForInstall(err), exitCode };
}

const header = `Linked package "${link_name}" declares peerDependencies that may not resolve from this project:`;

try {
// Registering the link (no args) should not warn: nothing is being resolved.
{
const { out, err, exitCode } = await run([bunExe(), "link"], link_dir);
expect(err).not.toContain("peerDependencies");
expect(out).toContain(`Success! Registered "${link_name}"`);
expect(exitCode).toBe(0);
}

// `bun link <name>` should warn once, listing peers not installed in the
// linked package and the remedy.
{
const { out, err, exitCode } = await run([bunExe(), "link", link_name], package_dir);
expect(err.split(header).length - 1).toBe(1);
expect(err).toContain("peer-one@^1.0.0");
expect(err).not.toContain("peer-one@^1.0.0 (optional)");
expect(err).toContain("peer-two@* (optional)");
expect(err).not.toContain("peer-three");
expect(err).toContain("resolve modules from their real location on disk");
expect(err).toContain("Install these peers in the linked package's own node_modules");
expect(out).toContain(`installed ${link_name}@link:${link_name}`);
expect(exitCode).toBe(0);
}

Check warning on line 539 in test/cli/install/bun-link.test.ts

View check run for this annotation

Claude / Claude Code Review

--silent suppression case dropped from test in 490dc6e

Commit 490dc6e dropped the `--silent` sub-block from this test, so the `pm.options.log_level != LogLevel::Silent` gate at `Package.rs:2226` now ships with zero coverage — and the PR description's Test section still lists "`--silent` suppresses the warning" as covered. Either restore a one-line `run([bunExe(), "link", "--silent", link_name], package_dir)` case asserting `err` does not contain the header, or drop the stale bullet from the PR body.
Comment thread
robobun marked this conversation as resolved.

// `bun install` with a `link:` dependency in package.json should warn too.
{
await rm(join(package_dir, "node_modules"), { recursive: true, force: true });
await rm(join(package_dir, "bun.lock"), { force: true });
await rm(join(package_dir, "bun.lockb"), { force: true });
await writeFile(
join(package_dir, "package.json"),
JSON.stringify({
name: "consumer",
version: "0.0.2",
dependencies: {
[link_name]: `link:${link_name}`,
},
}),
);
const { err } = await runBunInstall(env, package_dir, { allowWarnings: true });
expect(err.split(header).length - 1).toBe(1);
expect(err).toContain("peer-one@^1.0.0");
expect(err).not.toContain("peer-three");
}

// With every peer installed under the linked package, nothing is left to
// warn about.
{
for (const name of ["peer-one", "peer-two"]) {
await mkdir(join(link_dir, "node_modules", name), { recursive: true });
await writeFile(
join(link_dir, "node_modules", name, "package.json"),
JSON.stringify({ name, version: "1.0.0" }),
);
}
await rm(join(package_dir, "node_modules"), { recursive: true, force: true });
await rm(join(package_dir, "bun.lock"), { force: true });
await rm(join(package_dir, "bun.lockb"), { force: true });
const { err, exitCode } = await run([bunExe(), "link", link_name], package_dir);
expect(err).not.toContain("peerDependencies");
expect(exitCode).toBe(0);
}
} finally {
await run([bunExe(), "unlink"], link_dir);
}
});
Loading