Compare commits

...

7 Commits

Author SHA1 Message Date
Claude Bot
31ad2d9d70 Fix regex to match dependency paths with special characters
Update the regex in the regression test to use [^\n]+ instead of
[@\w./-]+ to match dependency paths that contain parentheses,
angle brackets, and spaces (e.g., "(root) > peer-deps-fixed").

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 00:40:16 +00:00
Claude Bot
0895f44d85 Use RegExp constructor for ANSI stripping to satisfy lint rule
Replace regex literal /\x1b\[[0-9;]*m/g with RegExp constructor
to avoid embedding control characters and satisfy Biome's
noControlCharactersInRegex lint rule.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 00:39:54 +00:00
Claude Bot
865a408616 Fix regression test 26076 to use standard VerdaccioRegistry pattern
- Use beforeAll/afterAll with start()/stop() instead of 'using' disposal
- Use registry.createTestDir() to properly configure bunfig with registry URL
- Test uses existing fixture packages (no-deps, peer-deps-fixed) from registry
- Removed setDefaultTimeout - test should be fast with local registry
- Updated regex to support scoped packages and hyphenated names

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 00:39:14 +00:00
Claude Bot
06a21e8c19 Address remaining review comments for peer dependency warnings
Test improvements:
- Line 7681: Use line-anchored regex pattern /^warn:[^\n]*...$/ to
  match only single lines (same fix as line 3916)

Regression test 26076 rewrite:
- Remove setDefaultTimeout and use local VerdaccioRegistry instead
  of network npm to make test hermetic and fast
- Fix await order: await stdout/stderr BEFORE exited for better
  error messages on failure
- Update regex pattern to support scoped packages and hyphenated
  names using /[@\w./-]+/ instead of /\w+/
- Publish test packages locally to create controlled peer dep
  mismatch scenario

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 00:29:39 +00:00
Claude Bot
669ffe3749 Improve peer dependency warning assertions in tests
Replace brittle substring assertions with more robust patterns:
- Line 3916: Use line-anchored pattern /^warn:[^\n]*...$/ to avoid
  matching across lines
- Line 7637: Use line-based check with split/some to ensure no
  individual line contains the warning
- Line 7720: Use line-based check with split/every for same purpose
- Line 7828: Use word-boundary regex /\bunmet peer dependency\b/i
- Line 7893: Use package-specific regex to target known packages
- Line 7959: Normalize stderr by stripping ANSI codes before checking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 00:27:36 +00:00
Claude Bot
7978a79339 fix: address code review comments for peer dependency warnings
- Add buffer overflow protection with "..." truncation marker
- Replace two loose substring assertions with single regex match

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 21:55:44 +00:00
Claude Bot
40c12d9ae6 fix(install): improve peer dependency warning messages
Previously, peer dependency warnings were unhelpful:
  warn: incorrect peer dependency "react@17.0.0"

This didn't tell users:
1. Which package requires the peer dependency
2. What version was expected
3. Why the installed version doesn't satisfy the requirement
4. How to trace the dependency that caused this

Now, warnings show the FULL dependency path and all relevant information:
  warn: (root) > @testing-library/react > react-dom has unmet peer dependency react@17.0.2 (found 17.0.0)

This is MORE helpful than pnpm's tree-style format because:
- Shows the complete dependency path in a single scannable line
- Uses ">" arrows to clearly show the dependency chain
- Includes "(root)" to show where the chain starts
- Shows both the expected version and what was found

Changes:
- Added `getPackageIdForDependencyId` helper to find the owning package
- Added `getDependencyPath` helper to trace dependency chain to root
- Added `emitUnmetPeerDependencyWarning` helper to reduce code duplication
- Updated tests to validate new warning format

Closes #26076

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 21:46:44 +00:00
3 changed files with 256 additions and 28 deletions

View File

@@ -1519,16 +1519,7 @@ fn getOrPutResolvedPackage(
const ver_tag = version.tag;
if ((res_tag == .npm and ver_tag == .npm) or (res_tag == .git and ver_tag == .git) or (res_tag == .github and ver_tag == .github)) {
const existing_package = this.lockfile.packages.get(existing_id);
this.log.addWarningFmt(
null,
logger.Loc.Empty,
this.allocator,
"incorrect peer dependency \"{f}@{f}\"",
.{
existing_package.name.fmt(this.lockfile.buffers.string_bytes.items),
existing_package.resolution.fmt(this.lockfile.buffers.string_bytes.items, .auto),
},
) catch unreachable;
emitUnmetPeerDependencyWarning(this, dependency_id, existing_package, version);
successFn(this, dependency_id, existing_id);
return .{
// we must fetch it from the packages array again, incase the package array mutates the value in the `successFn`
@@ -1556,16 +1547,7 @@ fn getOrPutResolvedPackage(
if ((res_tag == .npm and ver_tag == .npm) or (res_tag == .git and ver_tag == .git) or (res_tag == .github and ver_tag == .github)) {
const existing_package_id = list.items[0];
const existing_package = this.lockfile.packages.get(existing_package_id);
this.log.addWarningFmt(
null,
logger.Loc.Empty,
this.allocator,
"incorrect peer dependency \"{f}@{f}\"",
.{
existing_package.name.fmt(this.lockfile.buffers.string_bytes.items),
existing_package.resolution.fmt(this.lockfile.buffers.string_bytes.items, .auto),
},
) catch unreachable;
emitUnmetPeerDependencyWarning(this, dependency_id, existing_package, version);
successFn(this, dependency_id, list.items[0]);
return .{
// we must fetch it from the packages array again, incase the package array mutates the value in the `successFn`
@@ -1845,6 +1827,156 @@ fn getOrPutResolvedPackage(
}
}
/// Find the package that owns a given dependency_id by checking which package's
/// dependency slice contains the given ID.
fn getPackageIdForDependencyId(this: *PackageManager, dependency_id: DependencyID) ?PackageID {
const packages = this.lockfile.packages.slice();
const dep_slices = packages.items(.dependencies);
for (dep_slices, 0..) |dep_slice, pkg_id| {
if (dep_slice.contains(dependency_id)) {
return @intCast(pkg_id);
}
}
return null;
}
/// Emit a warning for an unmet peer dependency, showing the full dependency path.
fn emitUnmetPeerDependencyWarning(
this: *PackageManager,
dependency_id: DependencyID,
existing_package: Package,
version: Dependency.Version,
) void {
const string_buf = this.lockfile.buffers.string_bytes.items;
if (getPackageIdForDependencyId(this, dependency_id)) |parent_id| {
var path_buf: [1024]u8 = undefined;
const dep_path = getDependencyPath(this, parent_id, &path_buf);
this.log.addWarningFmt(
null,
logger.Loc.Empty,
this.allocator,
"{s} has unmet peer dependency {f}@{f} (found {f})",
.{
dep_path,
existing_package.name.fmt(string_buf),
version.literal.fmt(string_buf),
existing_package.resolution.fmt(string_buf, .auto),
},
) catch unreachable;
} else {
this.log.addWarningFmt(
null,
logger.Loc.Empty,
this.allocator,
"unmet peer dependency {f}@{f} (found {f})",
.{
existing_package.name.fmt(string_buf),
version.literal.fmt(string_buf),
existing_package.resolution.fmt(string_buf, .auto),
},
) catch unreachable;
}
}
/// Build the dependency path from root to a given package.
/// Returns a string like "root > @testing-library/react > react-dom" or just the package name if path can't be built.
fn getDependencyPath(this: *PackageManager, package_id: PackageID, buf: *[1024]u8) []const u8 {
const packages = this.lockfile.packages.slice();
const names = packages.items(.name);
const resolutions_slices = packages.items(.resolutions);
const string_buf = this.lockfile.buffers.string_bytes.items;
const all_resolutions = this.lockfile.buffers.resolutions.items;
// Build path by tracing back through dependency tree
// We store package IDs in reverse order, then build the string
var path_ids: [32]PackageID = undefined;
var path_len: usize = 0;
var current_id = package_id;
// Trace back to root (package 0)
while (current_id != 0 and path_len < 32) {
path_ids[path_len] = current_id;
path_len += 1;
// Find who depends on current_id
var found_parent = false;
for (resolutions_slices, 0..) |res_slice, pkg_id| {
const pkg_resolutions = res_slice.get(all_resolutions);
for (pkg_resolutions) |resolved_id| {
if (resolved_id == current_id) {
current_id = @intCast(pkg_id);
found_parent = true;
break;
}
}
if (found_parent) break;
}
if (!found_parent) break;
}
// Check if path was truncated due to depth limit
const was_truncated = current_id != 0 and path_len >= 32;
if (path_len == 0) {
// Just return the package name
const name = names[package_id].slice(string_buf);
const copy_len = @min(name.len, buf.len);
@memcpy(buf[0..copy_len], name[0..copy_len]);
return buf[0..copy_len];
}
// Build the path string in reverse order (root first)
var written: usize = 0;
// Indicate truncation or show root
if (was_truncated) {
const truncated_str = "... > ";
@memcpy(buf[written..][0..truncated_str.len], truncated_str);
written += truncated_str.len;
} else if (current_id == 0) {
const root_str = "(root)";
@memcpy(buf[written..][0..root_str.len], root_str);
written += root_str.len;
}
// Add packages in reverse order (from root to leaf)
var i = path_len;
while (i > 0) {
i -= 1;
const pkg_id = path_ids[i];
const name = names[pkg_id].slice(string_buf);
// Check if we have enough space for separator + full name
const separator_len: usize = if (written > 0) 3 else 0; // " > "
const needed = separator_len + name.len;
const remaining = buf.len - written;
if (needed > remaining) {
// Not enough space - add truncation marker and stop
const truncation_marker = "...";
if (remaining >= separator_len + truncation_marker.len) {
if (separator_len > 0) {
@memcpy(buf[written..][0..3], " > ");
written += 3;
}
@memcpy(buf[written..][0..truncation_marker.len], truncation_marker);
written += truncation_marker.len;
}
break;
}
if (separator_len > 0) {
@memcpy(buf[written..][0..3], " > ");
written += 3;
}
@memcpy(buf[written..][0..name.len], name);
written += name.len;
}
return buf[0..written];
}
fn resolutionSatisfiesDependency(this: *PackageManager, resolution: Resolution, dependency: Dependency.Version) bool {
const buf = this.lockfile.buffers.string_bytes.items;
if (resolution.tag == .npm and dependency.tag == .npm) {

View File

@@ -3735,7 +3735,7 @@ describe("hoisting", async () => {
expect(err).toContain("Saved lockfile");
expect(err).not.toContain("not found");
expect(err).not.toContain("error:");
expect(err).not.toContain("incorrect peer dependency");
expect(err).not.toContain("unmet peer dependency");
expect(out.replace(/\s*\[[0-9\.]+m?s\]\s*$/, "").split(/\r?\n/)).toEqual([
expect.stringContaining("bun install v1."),
@@ -3911,7 +3911,10 @@ describe("hoisting", async () => {
expect(err).toContain("Saved lockfile");
expect(err).not.toContain("not found");
expect(err).not.toContain("error:");
expect(err).toContain("incorrect peer dependency");
// New improved peer dependency warning format shows the requiring package, expected version, and actual version
// Match: "warn: ...peer-deps-fixed has unmet peer dependency no-deps@^1.0.0 (found 2.0.0)"
// Use line-anchored pattern to avoid matching across lines
expect(err).toMatch(/^warn:[^\n]*peer-deps-fixed has unmet peer dependency no-deps@\^1\.0\.0 \(found 2\.0\.0\)$/m);
expect(out.replace(/\s*\[[0-9\.]+m?s\]\s*$/, "").split(/\r?\n/)).toEqual([
expect.stringContaining("bun install v1."),
@@ -7632,7 +7635,8 @@ describe("yarn tests", () => {
expect(err).toContain("Saved lockfile");
expect(err).not.toContain("error:");
expect(err).not.toContain("not found");
expect(err).not.toContain("incorrect peer dependency");
// Use line-based check to ensure no line contains the peer dependency warning
expect(err.split(/\r?\n/).some((line: string) => /unmet peer dependency/i.test(line))).toBe(false);
expect(out.replace(/\s*\[[0-9\.]+m?s\]\s*$/, "").split(/\r?\n/)).toEqual([
expect.stringContaining("bun install v1."),
"",
@@ -7673,7 +7677,11 @@ describe("yarn tests", () => {
expect(err).toContain("Saved lockfile");
expect(err).not.toContain("error:");
expect(err).not.toContain("not found");
expect(err).toContain("incorrect peer dependency");
// New improved peer dependency warning format shows the requiring package, expected version, and actual version
// Use line-anchored pattern to avoid matching across lines
expect(err).toMatch(
/^warn:[^\n]*peer-deps-fixed[^\n]*has unmet peer dependency no-deps@\^1\.0\.0 \(found 2\.0\.0\)$/m,
);
expect(out.replace(/\s*\[[0-9\.]+m?s\]\s*$/, "").split(/\r?\n/)).toEqual([
expect.stringContaining("bun install v1."),
"",
@@ -7714,7 +7722,8 @@ describe("yarn tests", () => {
expect(err).toContain("Saved lockfile");
expect(err).not.toContain("error:");
expect(err).not.toContain("not found");
expect(err).not.toContain("incorrect peer dependency");
// Use line-based check to ensure no line contains the peer dependency warning
expect(err.split(/\r?\n/).every((line: string) => !/unmet peer dependency/i.test(line))).toBe(true);
expect(out.replace(/\s*\[[0-9\.]+m?s\]\s*$/, "").split(/\r?\n/)).toEqual([
expect.stringContaining("bun install v1."),
"",
@@ -7822,7 +7831,8 @@ describe("yarn tests", () => {
expect(err).toContain("Saved lockfile");
expect(err).not.toContain("error:");
expect(err).not.toContain("not found");
expect(err).not.toContain("incorrect peer dependency");
// Use word-boundary regex to match exact warning token
expect(err).not.toMatch(/\bunmet peer dependency\b/i);
expect(out.replace(/\s*\[[0-9\.]+m?s\]\s*$/, "").split(/\r?\n/)).toEqual([
expect.stringContaining("bun install v1."),
"",
@@ -7887,7 +7897,8 @@ describe("yarn tests", () => {
expect(err).toContain("Saved lockfile");
expect(err).not.toContain("error:");
expect(err).not.toContain("not found");
expect(err).not.toContain("incorrect peer dependency");
// Use regex to match the specific packages in this test, avoiding false positives
expect(err).not.toMatch(/unmet peer dependency.*(forward-peer-deps|no-deps)/i);
expect(out.replace(/\s*\[[0-9\.]+m?s\]\s*$/, "").split(/\r?\n/)).toEqual([
expect.stringContaining("bun install v1."),
"",
@@ -7953,7 +7964,10 @@ describe("yarn tests", () => {
expect(err).toContain("Saved lockfile");
expect(err).not.toContain("error:");
expect(err).not.toContain("not found");
expect(err).not.toContain("incorrect peer dependency");
// Normalize stderr (strip ANSI codes) and use case-insensitive regex
// Use RegExp constructor to avoid embedding control characters (satisfies noControlCharactersInRegex)
const normalizedErr = err.replace(new RegExp("\\x1b\\[[0-9;]*m", "g"), "");
expect(normalizedErr).not.toMatch(/unmet peer dependency/i);
expect(out.replace(/\s*\[[0-9\.]+m?s\]\s*$/, "").split(/\r?\n/)).toEqual([
expect.stringContaining("bun install v1."),
"",

View File

@@ -0,0 +1,82 @@
// Regression test for https://github.com/oven-sh/bun/issues/26076
// Peer dependency warnings should be more helpful by showing:
// 1. The full dependency path from root to the package requiring the peer dependency
// 2. What version range was expected
// 3. What version was actually installed
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { bunEnv, bunExe, VerdaccioRegistry } from "harness";
import { join } from "path";
let registry: VerdaccioRegistry;
let packageDir: string;
beforeAll(async () => {
registry = new VerdaccioRegistry();
await registry.start();
});
afterAll(() => {
registry.stop();
});
describe("issue #26076 - peer dependency warnings", () => {
test("warning message shows dependency path, expected version, and actual version", async () => {
// Create test directory with registry configured
// The registry already has:
// - no-deps@1.0.0 and no-deps@2.0.0
// - peer-deps-fixed@1.0.0 which has peerDependencies: { "no-deps": "^1.0.0" }
// Installing no-deps@2.0.0 should trigger warning because peer-deps-fixed needs ^1.0.0
({ packageDir } = await registry.createTestDir({
bunfigOpts: { linker: "hoisted" },
files: {
"package.json": JSON.stringify({
name: "test-peer-deps",
version: "1.0.0",
dependencies: {
"no-deps": "2.0.0",
"peer-deps-fixed": "1.0.0",
},
}),
},
}));
const env = {
...bunEnv,
BUN_INSTALL_CACHE_DIR: join(packageDir, ".bun-cache"),
};
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
env,
cwd: packageDir,
stdout: "pipe",
stderr: "pipe",
});
// Await stdout/stderr BEFORE exited for better error messages on failure
const stdout = await proc.stdout.text();
const stderr = await proc.stderr.text();
const exitCode = await proc.exited;
// The warning should show:
// 1. The package with unmet peer dependency: "peer-deps-fixed"
// 2. The unmet peer dependency with expected version: "no-deps@^1.0.0"
// 3. The actual version found: "(found 2.0.0)"
//
// Full format: "warn: (root) > peer-deps-fixed has unmet peer dependency no-deps@^1.0.0 (found 2.0.0)"
// Check for the "unmet peer dependency" message
expect(stderr).toContain("has unmet peer dependency");
// Check for expected vs actual version
expect(stderr).toContain("no-deps@^1.0.0");
expect(stderr).toContain("(found 2.0.0)");
// Verify the full format with regex - supports dependency paths like "(root) > peer-deps-fixed"
// Pattern matches: "warn: ... has unmet peer dependency <dep>@<version> (found <version>)"
// Use [^\n]* to match any characters in the dependency path (including parentheses, >, spaces)
expect(stderr).toMatch(/(?:warn:\s*)?[^\n]+ has unmet peer dependency [@\w./-]+@\S+ \(found \S+\)/);
expect(exitCode).toBe(0);
});
});