Compare commits

...

2 Commits

Author SHA1 Message Date
Jarred Sumner
b886c315fd Merge branch 'main' into claude/fix-13531-patch-path-resolution 2026-01-14 16:32:32 -08:00
Claude Bot
5be9cf0307 fix(install): resolve patchedDependencies paths relative to declaring package
When a package (pkgB) depends on a local folder dependency (pkgA) that has
`patchedDependencies`, bun install would fail because it looked for patch files
relative to the root project (pkgB) instead of the package that declared them
(pkgA).

This fix makes patch file paths absolute when parsing folder dependencies'
package.json files. When a non-main package declares patchedDependencies with
relative paths, we now resolve them relative to that package's directory rather
than always using the root project directory.

Fixes #13531

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:03:13 +00:00
3 changed files with 163 additions and 14 deletions

View File

@@ -1324,7 +1324,30 @@ pub fn Package(comptime SemverIntType: type) type {
const key = prop.key.?;
const value = prop.value.?;
if (key.isString() and value.isString()) {
string_builder.count(value.asString(allocator).?);
const relative_path = value.asString(allocator).?;
if (comptime !features.is_main) {
// For folder dependencies, we need to make the patch path absolute
// so it resolves relative to the folder dependency, not the root project
if (!std.fs.path.isAbsolute(relative_path)) {
// source.path.text is something like "/path/to/folder-dep/package.json"
// We need the directory part + relative_path
if (std.fs.path.dirname(source.path.text)) |package_dir| {
// Count the full absolute path length
// Path.joinAbsStringBuf joins with a separator, so we need:
// package_dir + "/" + relative_path
var abs_path_buf: bun.PathBuffer = undefined;
const abs_path = Path.joinAbsStringBuf(
package_dir,
&abs_path_buf,
&.{relative_path},
.auto,
);
string_builder.count(abs_path);
continue;
}
}
}
string_builder.count(relative_path);
}
}
}
@@ -1645,7 +1668,27 @@ pub fn Package(comptime SemverIntType: type) type {
if (key.isString() and value.isString()) {
var sfb = std.heap.stackFallback(1024, allocator);
const keyhash = try key.asStringHash(sfb.get(), String.Builder.stringHash) orelse unreachable;
const patch_path = string_builder.append(String, value.asString(allocator).?);
const relative_path = value.asString(allocator).?;
const patch_path = brk: {
if (comptime !features.is_main) {
// For folder dependencies, we need to make the patch path absolute
// so it resolves relative to the folder dependency, not the root project
if (!std.fs.path.isAbsolute(relative_path)) {
if (std.fs.path.dirname(source.path.text)) |package_dir| {
// Build the absolute path in a buffer first
var abs_path_buf: bun.PathBuffer = undefined;
const abs_path = Path.joinAbsStringBuf(
package_dir,
&abs_path_buf,
&.{relative_path},
.auto,
);
break :brk string_builder.append(String, abs_path);
}
}
}
break :brk string_builder.append(String, relative_path);
};
lockfile.patched_dependencies.put(allocator, keyhash, .{ .path = patch_path }) catch unreachable;
}
}

View File

@@ -236,10 +236,15 @@ pub const PatchTask = struct {
var absolute_patchfile_path_buf: bun.PathBuffer = undefined;
// 1. Parse the patch file
const absolute_patchfile_path = bun.path.joinZBuf(&absolute_patchfile_path_buf, &[_][]const u8{
dir,
patchfile_path,
}, .auto);
// If the patchfile_path is already absolute (e.g., from a folder dependency),
// use it directly. Otherwise, join with the project directory.
const absolute_patchfile_path = if (std.fs.path.isAbsolute(patchfile_path))
bun.path.joinZBuf(&absolute_patchfile_path_buf, &[_][]const u8{patchfile_path}, .auto)
else
bun.path.joinZBuf(&absolute_patchfile_path_buf, &[_][]const u8{
dir,
patchfile_path,
}, .auto);
// TODO: can the patch file be anything other than utf-8?
const patchfile_txt = switch (bun.sys.File.readFrom(
@@ -407,14 +412,19 @@ pub const PatchTask = struct {
var absolute_patchfile_path_buf: bun.PathBuffer = undefined;
// parse the patch file
const absolute_patchfile_path = bun.path.joinZBuf(
&absolute_patchfile_path_buf,
&[_][]const u8{
dir,
patchfile_path,
},
.auto,
);
// If the patchfile_path is already absolute (e.g., from a folder dependency),
// use it directly. Otherwise, join with the project directory.
const absolute_patchfile_path = if (std.fs.path.isAbsolute(patchfile_path))
bun.path.joinZBuf(&absolute_patchfile_path_buf, &[_][]const u8{patchfile_path}, .auto)
else
bun.path.joinZBuf(
&absolute_patchfile_path_buf,
&[_][]const u8{
dir,
patchfile_path,
},
.auto,
);
const stat: bun.Stat = switch (bun.sys.stat(absolute_patchfile_path)) {
.err => |e| {

View File

@@ -0,0 +1,96 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// https://github.com/oven-sh/bun/issues/13531
// When a package (pkgB) depends on a local path dependency (pkgA) that has `patchedDependencies`,
// `bun install` would fail because it looked for patch files relative to the root project (pkgB)
// instead of the dependency that declared them (pkgA).
describe("#13531 - patchedDependencies in folder dependency", () => {
test("bun install resolves patch paths relative to the folder dependency", async () => {
// Create a folder structure with:
// - pkgA: has a patchedDependency with a patch file in patches/
// - pkgB: depends on pkgA via "../pkgA"
using dir = tempDir("issue-13531", {
"pkgA/package.json": JSON.stringify({
name: "pkgA",
version: "1.0.0",
patchedDependencies: {
"is-number@7.0.0": "patches/is-number@7.0.0.patch",
},
dependencies: {
"is-number": "7.0.0",
},
}),
// Patch that adds 'use strict'; to the beginning of the file
"pkgA/patches/is-number@7.0.0.patch": `diff --git a/index.js b/index.js
index 27f4794..0000000 100644
--- a/index.js
+++ b/index.js
@@ -1,3 +1,4 @@
+'use strict';
module.exports = function(num) {
if (typeof num === 'number') {
return num - num === 0;
`,
"pkgB/package.json": JSON.stringify({
name: "pkgB",
version: "1.0.0",
dependencies: {
pkgA: "../pkgA",
},
}),
});
const pkgADir = `${dir}/pkgA`;
const pkgBDir = `${dir}/pkgB`;
// First, install pkgA (this should work even without the fix)
await using pkgAInstall = Bun.spawn({
cmd: [bunExe(), "install"],
env: bunEnv,
cwd: pkgADir,
stdout: "pipe",
stderr: "pipe",
});
const [, pkgAStderr, pkgAExitCode] = await Promise.all([
pkgAInstall.stdout.text(),
pkgAInstall.stderr.text(),
pkgAInstall.exited,
]);
expect(pkgAStderr).not.toContain("Couldn't find patch file");
expect(pkgAExitCode).toBe(0);
// Now install pkgB - this is where the bug was triggered
// Before the fix, bun would look for the patch file at:
// pkgB/patches/is-number@7.0.0.patch
// instead of:
// pkgA/patches/is-number@7.0.0.patch
await using pkgBInstall = Bun.spawn({
cmd: [bunExe(), "install"],
env: bunEnv,
cwd: pkgBDir,
stdout: "pipe",
stderr: "pipe",
});
const [, pkgBStderr, pkgBExitCode] = await Promise.all([
pkgBInstall.stdout.text(),
pkgBInstall.stderr.text(),
pkgBInstall.exited,
]);
// The main assertion - install should succeed without patch file not found errors
expect(pkgBStderr).not.toContain("Couldn't find patch file");
expect(pkgBExitCode).toBe(0);
// Verify the patch was actually applied by checking for the unique 'use strict';
// marker that our patch adds at the beginning of the file.
// The original is-number package does NOT start with 'use strict';
const isNumberPath = `${pkgBDir}/node_modules/is-number/index.js`;
const isNumberContent = await Bun.file(isNumberPath).text();
// The patch adds 'use strict'; as the first line - this is unique to our patch
expect(isNumberContent.startsWith("'use strict';")).toBe(true);
});
});