From 58401e05a13395b58c7863e0e12a6049a208e979 Mon Sep 17 00:00:00 2001 From: Thijs Koerselman Date: Sat, 29 Aug 2026 10:44:07 +0200 Subject: [PATCH 1/8] Write pnpm 11 patch metadata in its native format --- src/isolate.ts | 6 ++- ...generate-pnpm-lockfile.integration.test.ts | 44 ++++++++++++++++++- .../helpers/generate-pnpm-lockfile.test.ts | 38 ++++++++++++++++ .../helpers/generate-pnpm-lockfile.ts | 21 +++++---- .../write-isolate-pnpm-workspace.test.ts | 37 ++++++++++++++++ .../patches/write-isolate-pnpm-workspace.ts | 25 ++++++++--- 6 files changed, 155 insertions(+), 16 deletions(-) diff --git a/src/isolate.ts b/src/isolate.ts index f4d4fd0..38ba19e 100644 --- a/src/isolate.ts +++ b/src/isolate.ts @@ -350,7 +350,10 @@ export function createIsolator(initialConfig?: IsolateConfig) { if (packageManager.name === "bun") { manifest.patchedDependencies = patchEntries; - } else { + } else if ( + packageManager.name !== "pnpm" || + packageManager.majorVersion < 11 + ) { manifest.pnpm ??= {}; manifest.pnpm.patchedDependencies = patchEntries; } @@ -400,6 +403,7 @@ export function createIsolator(initialConfig?: IsolateConfig) { writeIsolatePnpmWorkspace({ workspaceRootDir, isolateDir, + majorVersion: packageManager.majorVersion, copiedPatches, }); } diff --git a/src/lib/lockfile/helpers/generate-pnpm-lockfile.integration.test.ts b/src/lib/lockfile/helpers/generate-pnpm-lockfile.integration.test.ts index 9430a2a..603e4dc 100644 --- a/src/lib/lockfile/helpers/generate-pnpm-lockfile.integration.test.ts +++ b/src/lib/lockfile/helpers/generate-pnpm-lockfile.integration.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { parseAllDocuments } from "yaml"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { PatchFile } from "#/lib/types"; import { generatePnpmLockfile } from "./generate-pnpm-lockfile"; const FIXTURES_DIR = path.join(import.meta.dirname, "__fixtures__"); @@ -81,11 +82,15 @@ describe("generatePnpmLockfile integration", () => { packageManager, fixture = "pnpm-two-document", mutateLockfile, + majorVersion = 12, + patchedDependencies, }: { packageManager: string | undefined; fixture?: string; /** Rewrite the workspace lockfile before isolating, to shape its env document */ mutateLockfile?: (lockfile: string) => string; + majorVersion?: number; + patchedDependencies?: Record; }) { const { tmpBase, workspaceRoot } = await setupFixture(fixture); cleanupPaths.push(tmpBase); @@ -114,8 +119,9 @@ describe("generatePnpmLockfile integration", () => { dependencies: { "left-pad": "1.3.0" }, ...(packageManager ? { packageManager } : {}), }, - majorVersion: 12, + majorVersion, includeDevDependencies: false, + patchedDependencies, }); return readLockfileDocuments(isolateDir); @@ -132,6 +138,42 @@ describe("generatePnpmLockfile integration", () => { }); }); + it("writes patched dependencies in pnpm 10's object format", async () => { + const { documents } = await isolate({ + packageManager: "pnpm@10.0.0", + majorVersion: 10, + patchedDependencies: { + "left-pad@1.3.0": { + path: "patches/left-pad@1.3.0.patch", + hash: "sha256-pnpm10", + }, + }, + }); + + expect(documents.at(-1)?.patchedDependencies).toEqual({ + "left-pad@1.3.0": { + path: "patches/left-pad@1.3.0.patch", + hash: "sha256-pnpm10", + }, + }); + }); + + it("writes patched dependencies in pnpm 12's hash format", async () => { + const { documents } = await isolate({ + packageManager: "pnpm@12.0.0", + patchedDependencies: { + "left-pad@1.3.0": { + path: "patches/left-pad@1.3.0.patch", + hash: "sha256-pnpm12", + }, + }, + }); + + expect(documents.at(-1)?.patchedDependencies).toEqual({ + "left-pad@1.3.0": "sha256-pnpm12", + }); + }); + it("carries the env document into the isolated lockfile", async () => { const { content, documents } = await isolate({ packageManager: "pnpm@12.0.0", diff --git a/src/lib/lockfile/helpers/generate-pnpm-lockfile.test.ts b/src/lib/lockfile/helpers/generate-pnpm-lockfile.test.ts index 91687bf..b12b1f0 100644 --- a/src/lib/lockfile/helpers/generate-pnpm-lockfile.test.ts +++ b/src/lib/lockfile/helpers/generate-pnpm-lockfile.test.ts @@ -451,6 +451,44 @@ describe("generatePnpmLockfile", () => { expect(writtenLockfile.patchedDependencies).toEqual(patchedDependencies); }); + it("writes patched dependency hashes for pnpm 11 and later", async () => { + const lockfile = createMockLockfile(); + readWantedLockfile_v9.mockResolvedValue(lockfile as never); + getLockfileImporterId_v9.mockReturnValue("apps/my-app" as never); + pruneLockfile_v9.mockImplementation((lf) => lf as never); + + await generatePnpmLockfile({ + workspaceRootDir: "/workspace", + targetPackageDir: "/workspace/apps/my-app", + isolateDir: "/workspace/apps/my-app/isolate", + internalDepPackageNames: ["shared"], + packagesRegistry: { + shared: { + absoluteDir: "/workspace/packages/shared", + rootRelativeDir: "packages/shared", + manifest: { name: "shared", version: "1.0.0" }, + }, + }, + targetPackageManifest: { name: "my-app", version: "1.0.0" }, + majorVersion: 11, + includeDevDependencies: false, + patchedDependencies: { + "lodash@4.17.21": { + path: "patches/lodash.patch", + hash: "sha256-abc123", + }, + }, + }); + + const writeCall = writeWantedLockfile_v9.mock.calls[0]!; + const writtenLockfile = writeCall[1] as { + patchedDependencies?: Record; + }; + expect(writtenLockfile.patchedDependencies).toEqual({ + "lodash@4.17.21": "sha256-abc123", + }); + }); + it("should throw when lockfile is not found", async () => { readWantedLockfile_v9.mockResolvedValue(null as never); diff --git a/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts b/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts index 869b71b..7c0c623 100644 --- a/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts +++ b/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts @@ -221,17 +221,22 @@ export async function generatePnpmLockfile({ */ if (useVersion9) { /** - * The pruner and the writer come from separate packages with their own - * copies of the lockfile types, which disagree on `lockfileVersion` and on - * `patchedDependencies`: the modern writer types the latter as a map of - * bare hashes, because pnpm 11 simplified the on-disk format (see issue - * #201). We keep writing the `{ path, hash }` form that pnpm 9 and 10 - * expect — pnpm 11 and up migrate it when reading, and the writer passes - * the value through untouched. + * pnpm 11 stores only the patch hash in the lockfile. The matching path + * is written to pnpm-workspace.yaml by writeIsolatePnpmWorkspace. */ + const lockfilePatchedDependencies = + majorVersion >= 11 && patchedDependencies + ? Object.fromEntries( + Object.entries(patchedDependencies).map(([spec, patchFile]) => [ + spec, + patchFile.hash, + ]), + ) + : patchedDependencies; + await writeWantedLockfile_v9(isolateDir, { ...prunedLockfile, - patchedDependencies, + patchedDependencies: lockfilePatchedDependencies, } as unknown as Parameters[1]); /** diff --git a/src/lib/patches/write-isolate-pnpm-workspace.test.ts b/src/lib/patches/write-isolate-pnpm-workspace.test.ts index d2f7daf..07536b0 100644 --- a/src/lib/patches/write-isolate-pnpm-workspace.test.ts +++ b/src/lib/patches/write-isolate-pnpm-workspace.test.ts @@ -50,6 +50,7 @@ describe("writeIsolatePnpmWorkspace", () => { writeIsolatePnpmWorkspace({ workspaceRootDir, isolateDir, + majorVersion: 10, copiedPatches, }); @@ -77,6 +78,7 @@ describe("writeIsolatePnpmWorkspace", () => { writeIsolatePnpmWorkspace({ workspaceRootDir, isolateDir, + majorVersion: 10, copiedPatches: {}, }); @@ -95,6 +97,7 @@ describe("writeIsolatePnpmWorkspace", () => { writeIsolatePnpmWorkspace({ workspaceRootDir, isolateDir, + majorVersion: 10, copiedPatches: {}, }); @@ -105,6 +108,35 @@ describe("writeIsolatePnpmWorkspace", () => { ); }); + it("writes copied patch paths to pnpm 11 workspaces", () => { + readTypedYamlSync.mockReturnValue({ + packages: ["packages/*"], + }); + + writeIsolatePnpmWorkspace({ + workspaceRootDir, + isolateDir, + majorVersion: 11, + copiedPatches: { + "lodash@4.17.21": { + path: "patches/lodash@4.17.21.patch", + hash: "sha256-abc123", + }, + }, + }); + + expect(fs.copyFileSync).not.toHaveBeenCalled(); + expect(writeTypedYamlSync).toHaveBeenCalledWith( + "/workspace/isolate/pnpm-workspace.yaml", + { + packages: ["packages/*"], + patchedDependencies: { + "lodash@4.17.21": "patches/lodash@4.17.21.patch", + }, + }, + ); + }); + it("preserves unrelated top-level fields", () => { readTypedYamlSync.mockReturnValue({ packages: ["packages/*"], @@ -123,6 +155,7 @@ describe("writeIsolatePnpmWorkspace", () => { writeIsolatePnpmWorkspace({ workspaceRootDir, isolateDir, + majorVersion: 10, copiedPatches, }); @@ -160,6 +193,7 @@ describe("writeIsolatePnpmWorkspace", () => { writeIsolatePnpmWorkspace({ workspaceRootDir, isolateDir, + majorVersion: 11, copiedPatches: {}, }); @@ -201,6 +235,7 @@ describe("writeIsolatePnpmWorkspace", () => { writeIsolatePnpmWorkspace({ workspaceRootDir, isolateDir, + majorVersion: 11, copiedPatches, }); @@ -239,6 +274,7 @@ describe("writeIsolatePnpmWorkspace", () => { writeIsolatePnpmWorkspace({ workspaceRootDir, isolateDir, + majorVersion: 10, copiedPatches, }); @@ -257,6 +293,7 @@ describe("writeIsolatePnpmWorkspace", () => { writeIsolatePnpmWorkspace({ workspaceRootDir, isolateDir, + majorVersion: 10, copiedPatches: {}, }); diff --git a/src/lib/patches/write-isolate-pnpm-workspace.ts b/src/lib/patches/write-isolate-pnpm-workspace.ts index e5aeee3..1696f91 100644 --- a/src/lib/patches/write-isolate-pnpm-workspace.ts +++ b/src/lib/patches/write-isolate-pnpm-workspace.ts @@ -16,20 +16,25 @@ import { readTypedYamlSync, writeTypedYamlSync } from "#/lib/utils"; * any of the following hold: * * - The source yaml cannot be read or parsed. - * - The parsed settings have no `patchedDependencies` field. + * - The parsed settings have no `patchedDependencies` field and pnpm is older + * than version 11. * - Every entry in `patchedDependencies` is also present in `copiedPatches` - * (no exclusions, so rewriting would only churn formatting). + * and pnpm is older than version 11. * * Otherwise, `patchedDependencies` is rewritten to the entries in - * `copiedPatches` (or removed entirely when none remain). + * `copiedPatches` (or removed entirely when none remain). pnpm 11 and later + * always use this field for copied patches because their lockfile stores only + * the patch hash. */ export function writeIsolatePnpmWorkspace({ workspaceRootDir, isolateDir, + majorVersion, copiedPatches, }: { workspaceRootDir: string; isolateDir: string; + majorVersion: number; copiedPatches: Record; }) { const log = useLogger(); @@ -48,7 +53,15 @@ export function writeIsolatePnpmWorkspace({ return; } - if (!settings || !settings.patchedDependencies) { + if (!settings) { + fs.copyFileSync(sourcePath, targetPath); + return; + } + + const needsPnpm11PatchPaths = + majorVersion >= 11 && Object.keys(copiedPatches).length > 0; + + if (!settings.patchedDependencies && !needsPnpm11PatchPaths) { fs.copyFileSync(sourcePath, targetPath); return; } @@ -57,11 +70,11 @@ export function writeIsolatePnpmWorkspace({ * If every patch declared in the source yaml was kept, copy verbatim so * comments, ordering, and trailing whitespace are preserved. */ - const sourceSpecs = Object.keys(settings.patchedDependencies); + const sourceSpecs = Object.keys(settings.patchedDependencies ?? {}); const copiedSpecs = new Set(Object.keys(copiedPatches)); const hasExclusions = sourceSpecs.some((spec) => !copiedSpecs.has(spec)); - if (!hasExclusions) { + if (!hasExclusions && !needsPnpm11PatchPaths) { fs.copyFileSync(sourcePath, targetPath); return; } From 38d759df7f4c02ef35eacd98d32b45f108be8204 Mon Sep 17 00:00:00 2001 From: Thijs Koerselman Date: Sat, 29 Aug 2026 10:53:21 +0200 Subject: [PATCH 2/8] Address code-review findings (Rush patches and output coverage) --- docs/patched-dependencies.md | 5 +- src/isolate.integration.test.ts | 90 +++++++++++++++++++ src/isolate.ts | 35 +++++--- .../helpers/generate-pnpm-lockfile.test.ts | 32 +++++++ .../helpers/generate-pnpm-lockfile.ts | 9 ++ .../write-isolate-pnpm-workspace.test.ts | 29 +++++- .../patches/write-isolate-pnpm-workspace.ts | 36 +++++++- 7 files changed, 217 insertions(+), 19 deletions(-) diff --git a/docs/patched-dependencies.md b/docs/patched-dependencies.md index ed13df2..64d1033 100644 --- a/docs/patched-dependencies.md +++ b/docs/patched-dependencies.md @@ -15,5 +15,6 @@ Patches are filtered based on the target package's dependencies: - Patches for packages not in the target's dependency tree are excluded The patch files are copied to the isolated output, preserving their original -directory structure. The lockfile and `package.json` are updated with the correct -paths. +directory structure. pnpm 9 and 10 store each patch path and hash in the +lockfile. pnpm 11 and later store the hash in the lockfile and the copied patch +path in `pnpm-workspace.yaml`. Bun stores patch paths in `package.json`. diff --git a/src/isolate.integration.test.ts b/src/isolate.integration.test.ts index a20b4fa..287ccbf 100644 --- a/src/isolate.integration.test.ts +++ b/src/isolate.integration.test.ts @@ -1,6 +1,7 @@ import fs from "fs-extra"; import os from "node:os"; import path from "node:path"; +import { parse } from "yaml"; import { afterEach, describe, expect, it } from "vitest"; import { isolate } from "./isolate"; @@ -65,4 +66,93 @@ describe("isolate integration", () => { fs.readJson(path.join(result, "package.json")), ).resolves.toMatchObject({ name: "functions-node", version: "1.0.0" }); }); + + it("writes pnpm 11 patch settings to the lockfile and workspace configuration", async () => { + const workspaceRoot = await fs.mkdtemp( + path.join(os.tmpdir(), "isolate-pnpm-patches-test-"), + ); + temporaryDirectories.push(workspaceRoot); + const targetPackageDir = path.join(workspaceRoot, "packages", "functions"); + await fs.ensureDir(targetPackageDir); + await fs.ensureDir(path.join(workspaceRoot, "patches")); + await fs.writeJson(path.join(workspaceRoot, "package.json"), { + name: "workspace", + version: "1.0.0", + private: true, + }); + await fs.writeFile( + path.join(workspaceRoot, "pnpm-workspace.yaml"), + [ + "packages:", + " - packages/*", + "patchedDependencies:", + " left-pad@1.3.0: patches/left-pad@1.3.0.patch", + "", + ].join("\n"), + ); + await fs.writeFile( + path.join(workspaceRoot, "pnpm-lock.yaml"), + [ + "lockfileVersion: '9.0'", + "", + "settings:", + " autoInstallPeers: false", + "", + "importers:", + "", + " packages/functions:", + " dependencies:", + " left-pad:", + " specifier: 1.3.0", + " version: 1.3.0", + "", + "packages:", + "", + " left-pad@1.3.0:", + " resolution: {integrity: sha512-1}", + "", + "snapshots:", + "", + " left-pad@1.3.0: {}", + "", + "patchedDependencies:", + " left-pad@1.3.0: sha256-patched", + "", + ].join("\n"), + ); + await fs.writeFile( + path.join(workspaceRoot, "patches", "left-pad@1.3.0.patch"), + "diff --git a/index.js b/index.js\n", + ); + await fs.writeJson(path.join(targetPackageDir, "package.json"), { + name: "functions", + version: "1.0.0", + main: "./index.js", + files: ["index.js"], + dependencies: { "left-pad": "1.3.0" }, + }); + await fs.writeFile(path.join(targetPackageDir, "index.js"), "export {};\n"); + + const isolateDir = await isolate({ + targetPackagePath: targetPackageDir, + buildDirName: ".", + workspaceRoot: "../..", + }); + + const manifest = await fs.readJson(path.join(isolateDir, "package.json")); + const workspaceSettings = parse( + await fs.readFile(path.join(isolateDir, "pnpm-workspace.yaml"), "utf8"), + ); + const lockfile = parse( + await fs.readFile(path.join(isolateDir, "pnpm-lock.yaml"), "utf8"), + ); + + expect(manifest.pnpm?.patchedDependencies).toBeUndefined(); + expect(workspaceSettings.patchedDependencies).toEqual({ + "left-pad@1.3.0": "patches/left-pad@1.3.0.patch", + }); + expect(lockfile.patchedDependencies).toEqual({ + "left-pad@1.3.0": "sha256-patched", + }); + }); }); diff --git a/src/isolate.ts b/src/isolate.ts index 38ba19e..717585c 100644 --- a/src/isolate.ts +++ b/src/isolate.ts @@ -28,7 +28,10 @@ import { import { detectPackageManager, shouldUsePnpmPack } from "./lib/package-manager"; import { getVersion } from "./lib/package-manager/helpers/infer-from-files"; import { copyPatches } from "./lib/patches/copy-patches"; -import { writeIsolatePnpmWorkspace } from "./lib/patches/write-isolate-pnpm-workspace"; +import { + writeGeneratedIsolatePnpmWorkspace, + writeIsolatePnpmWorkspace, +} from "./lib/patches/write-isolate-pnpm-workspace"; import { createPackagesRegistry, listInternalPackages } from "./lib/registry"; import type { PackageManifest } from "./lib/types"; import { @@ -37,7 +40,6 @@ import { isRushWorkspace, readTypedJson, resetIsolateDir, - writeTypedYamlSync, } from "./lib/utils"; const __dirname = getDirname(import.meta.url); @@ -331,16 +333,12 @@ export function createIsolator(initialConfig?: IsolateConfig) { const hasCopiedPatches = Object.keys(copiedPatches).length > 0; - /** Update manifest if patches were copied or npm fallback is needed */ + /** Update the manifest for copied patches when its format carries them or for an npm fallback. */ if (hasCopiedPatches || usedFallbackToNpm) { const manifest = await readManifest(isolateDir); if (hasCopiedPatches) { - /** - * Extract just the paths for the manifest (lockfile needs full - * PatchFile). PNPM stores patches under pnpm.patchedDependencies, Bun - * at the top level. - */ + /** Extract paths for package managers that read patch settings from the manifest. */ const patchEntries = Object.fromEntries( Object.entries(copiedPatches).map(([spec, patchFile]) => [ spec, @@ -350,17 +348,19 @@ export function createIsolator(initialConfig?: IsolateConfig) { if (packageManager.name === "bun") { manifest.patchedDependencies = patchEntries; + log.debug( + `Added ${Object.keys(copiedPatches).length} patches to isolated package.json`, + ); } else if ( packageManager.name !== "pnpm" || packageManager.majorVersion < 11 ) { manifest.pnpm ??= {}; manifest.pnpm.patchedDependencies = patchEntries; + log.debug( + `Added ${Object.keys(copiedPatches).length} patches to isolated package.json`, + ); } - - log.debug( - `Added ${Object.keys(copiedPatches).length} patches to isolated package.json`, - ); } if (usedFallbackToNpm) { @@ -396,8 +396,11 @@ export function createIsolator(initialConfig?: IsolateConfig) { const packages = packagesFolderNames.map((x) => path.join(x, "/*")); - writeTypedYamlSync(path.join(isolateDir, "pnpm-workspace.yaml"), { + writeGeneratedIsolatePnpmWorkspace({ + isolateDir, packages, + majorVersion: packageManager.majorVersion, + copiedPatches, }); } else { writeIsolatePnpmWorkspace({ @@ -406,6 +409,12 @@ export function createIsolator(initialConfig?: IsolateConfig) { majorVersion: packageManager.majorVersion, copiedPatches, }); + + if (packageManager.majorVersion >= 11 && hasCopiedPatches) { + log.debug( + `Added ${Object.keys(copiedPatches).length} patches to isolated pnpm-workspace.yaml`, + ); + } } } diff --git a/src/lib/lockfile/helpers/generate-pnpm-lockfile.test.ts b/src/lib/lockfile/helpers/generate-pnpm-lockfile.test.ts index b12b1f0..4bb4555 100644 --- a/src/lib/lockfile/helpers/generate-pnpm-lockfile.test.ts +++ b/src/lib/lockfile/helpers/generate-pnpm-lockfile.test.ts @@ -489,6 +489,38 @@ describe("generatePnpmLockfile", () => { }); }); + it("rejects a pnpm 11 patch without a lockfile hash", async () => { + const lockfile = createMockLockfile(); + readWantedLockfile_v9.mockResolvedValue(lockfile as never); + getLockfileImporterId_v9.mockReturnValue("apps/my-app" as never); + pruneLockfile_v9.mockImplementation((lf) => lf as never); + + await expect( + generatePnpmLockfile({ + workspaceRootDir: "/workspace", + targetPackageDir: "/workspace/apps/my-app", + isolateDir: "/workspace/apps/my-app/isolate", + internalDepPackageNames: ["shared"], + packagesRegistry: { + shared: { + absoluteDir: "/workspace/packages/shared", + rootRelativeDir: "packages/shared", + manifest: { name: "shared", version: "1.0.0" }, + }, + }, + targetPackageManifest: { name: "my-app", version: "1.0.0" }, + majorVersion: 11, + includeDevDependencies: false, + patchedDependencies: { + "lodash@4.17.21": { + path: "patches/lodash.patch", + hash: "", + }, + }, + }), + ).rejects.toThrow("Patch lodash@4.17.21 has no lockfile hash"); + }); + it("should throw when lockfile is not found", async () => { readWantedLockfile_v9.mockResolvedValue(null as never); diff --git a/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts b/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts index 7c0c623..136ca37 100644 --- a/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts +++ b/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts @@ -224,6 +224,15 @@ export async function generatePnpmLockfile({ * pnpm 11 stores only the patch hash in the lockfile. The matching path * is written to pnpm-workspace.yaml by writeIsolatePnpmWorkspace. */ + const patchWithoutHash = Object.entries(patchedDependencies ?? {}).find( + ([, patchFile]) => majorVersion >= 11 && !patchFile.hash, + ); + + assert( + !patchWithoutHash, + `Patch ${patchWithoutHash?.[0]} has no lockfile hash`, + ); + const lockfilePatchedDependencies = majorVersion >= 11 && patchedDependencies ? Object.fromEntries( diff --git a/src/lib/patches/write-isolate-pnpm-workspace.test.ts b/src/lib/patches/write-isolate-pnpm-workspace.test.ts index 07536b0..c799dea 100644 --- a/src/lib/patches/write-isolate-pnpm-workspace.test.ts +++ b/src/lib/patches/write-isolate-pnpm-workspace.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PatchFile } from "#/lib/types"; -import { writeIsolatePnpmWorkspace } from "./write-isolate-pnpm-workspace"; +import { + writeGeneratedIsolatePnpmWorkspace, + writeIsolatePnpmWorkspace, +} from "./write-isolate-pnpm-workspace"; vi.mock("fs-extra", () => ({ default: { @@ -137,6 +140,30 @@ describe("writeIsolatePnpmWorkspace", () => { ); }); + it("writes copied patch paths to generated pnpm 11 workspaces", () => { + writeGeneratedIsolatePnpmWorkspace({ + isolateDir, + majorVersion: 11, + packages: ["packages/*"], + copiedPatches: { + "lodash@4.17.21": { + path: "patches/lodash@4.17.21.patch", + hash: "sha256-abc123", + }, + }, + }); + + expect(writeTypedYamlSync).toHaveBeenCalledWith( + "/workspace/isolate/pnpm-workspace.yaml", + { + packages: ["packages/*"], + patchedDependencies: { + "lodash@4.17.21": "patches/lodash@4.17.21.patch", + }, + }, + ); + }); + it("preserves unrelated top-level fields", () => { readTypedYamlSync.mockReturnValue({ packages: ["packages/*"], diff --git a/src/lib/patches/write-isolate-pnpm-workspace.ts b/src/lib/patches/write-isolate-pnpm-workspace.ts index 1696f91..ced1bf7 100644 --- a/src/lib/patches/write-isolate-pnpm-workspace.ts +++ b/src/lib/patches/write-isolate-pnpm-workspace.ts @@ -4,6 +4,10 @@ import { useLogger } from "#/lib/logger"; import type { PatchFile, PnpmSettings } from "#/lib/types"; import { readTypedYamlSync, writeTypedYamlSync } from "#/lib/utils"; +type GeneratedPnpmWorkspaceSettings = PnpmSettings & { + packages: string[]; +}; + /** * Copy `pnpm-workspace.yaml` from the workspace root to the isolate directory, * filtering its `patchedDependencies` field so it only references patches that @@ -11,9 +15,9 @@ import { readTypedYamlSync, writeTypedYamlSync } from "#/lib/utils"; * isolate fails when patches that don't apply to the target package are * declared in the workspace root config (see issue #178). * - * The yaml is only rewritten when filtering is required. The file is copied - * verbatim — preserving comments, key order, and trailing whitespace — when - * any of the following hold: + * The yaml is rewritten when filtering changes patch entries or when pnpm 11 + * and later need copied patch paths. Otherwise, it is copied verbatim to + * preserve comments, key order, and trailing whitespace. * * - The source yaml cannot be read or parsed. * - The parsed settings have no `patchedDependencies` field and pnpm is older @@ -91,3 +95,29 @@ export function writeIsolatePnpmWorkspace({ writeTypedYamlSync(targetPath, settings); } + +/** Write the workspace configuration generated for a Rush isolate. */ +export function writeGeneratedIsolatePnpmWorkspace({ + isolateDir, + packages, + majorVersion, + copiedPatches, +}: { + isolateDir: string; + packages: string[]; + majorVersion: number; + copiedPatches: Record; +}) { + const settings: GeneratedPnpmWorkspaceSettings = { packages }; + + if (majorVersion >= 11 && Object.keys(copiedPatches).length > 0) { + settings.patchedDependencies = Object.fromEntries( + Object.entries(copiedPatches).map(([spec, patchFile]) => [ + spec, + patchFile.path, + ]), + ); + } + + writeTypedYamlSync(path.join(isolateDir, "pnpm-workspace.yaml"), settings); +} From e08ea0244df13cd91e483744a43f3521559baab1 Mon Sep 17 00:00:00 2001 From: Thijs Koerselman Date: Sat, 29 Aug 2026 11:06:27 +0200 Subject: [PATCH 3/8] Centralize pnpm patch format handling --- docs/patched-dependencies.md | 9 +- src/isolate.integration.test.ts | 1 + src/isolate.ts | 39 ++++---- .../helpers/generate-pnpm-lockfile.ts | 23 ++--- .../patches/pnpm-patched-dependencies.test.ts | 55 +++++++++++ src/lib/patches/pnpm-patched-dependencies.ts | 57 +++++++++++ .../write-isolate-pnpm-workspace.test.ts | 95 +++++++++++++++++++ .../patches/write-isolate-pnpm-workspace.ts | 59 ++++++++---- 8 files changed, 288 insertions(+), 50 deletions(-) create mode 100644 src/lib/patches/pnpm-patched-dependencies.test.ts create mode 100644 src/lib/patches/pnpm-patched-dependencies.ts diff --git a/docs/patched-dependencies.md b/docs/patched-dependencies.md index 64d1033..44b85cf 100644 --- a/docs/patched-dependencies.md +++ b/docs/patched-dependencies.md @@ -15,6 +15,9 @@ Patches are filtered based on the target package's dependencies: - Patches for packages not in the target's dependency tree are excluded The patch files are copied to the isolated output, preserving their original -directory structure. pnpm 9 and 10 store each patch path and hash in the -lockfile. pnpm 11 and later store the hash in the lockfile and the copied patch -path in `pnpm-workspace.yaml`. Bun stores patch paths in `package.json`. +directory structure. pnpm 9 and 10 store the path in +`package.json`'s `pnpm.patchedDependencies`; when the source workspace +configuration declares the paths, its copied or filtered +`pnpm-workspace.yaml` does too. Their lockfile entries include both the path and +hash. pnpm 11 and later store the hash in the lockfile and the copied patch path +in `pnpm-workspace.yaml`. Bun stores patch paths in `package.json`. diff --git a/src/isolate.integration.test.ts b/src/isolate.integration.test.ts index 287ccbf..465bf29 100644 --- a/src/isolate.integration.test.ts +++ b/src/isolate.integration.test.ts @@ -79,6 +79,7 @@ describe("isolate integration", () => { name: "workspace", version: "1.0.0", private: true, + packageManager: "pnpm@11.0.0", }); await fs.writeFile( path.join(workspaceRoot, "pnpm-workspace.yaml"), diff --git a/src/isolate.ts b/src/isolate.ts index 717585c..9bf4207 100644 --- a/src/isolate.ts +++ b/src/isolate.ts @@ -28,6 +28,7 @@ import { import { detectPackageManager, shouldUsePnpmPack } from "./lib/package-manager"; import { getVersion } from "./lib/package-manager/helpers/infer-from-files"; import { copyPatches } from "./lib/patches/copy-patches"; +import { getPnpmPatchedDependenciesOutput } from "./lib/patches/pnpm-patched-dependencies"; import { writeGeneratedIsolatePnpmWorkspace, writeIsolatePnpmWorkspace, @@ -332,34 +333,36 @@ export function createIsolator(initialConfig?: IsolateConfig) { }); const hasCopiedPatches = Object.keys(copiedPatches).length > 0; + const pnpmPatchOutput = getPnpmPatchedDependenciesOutput({ + majorVersion: packageManager.majorVersion, + copiedPatches, + }); /** Update the manifest for copied patches when its format carries them or for an npm fallback. */ if (hasCopiedPatches || usedFallbackToNpm) { const manifest = await readManifest(isolateDir); if (hasCopiedPatches) { - /** Extract paths for package managers that read patch settings from the manifest. */ - const patchEntries = Object.fromEntries( - Object.entries(copiedPatches).map(([spec, patchFile]) => [ - spec, - patchFile.path, - ]), - ); - if (packageManager.name === "bun") { - manifest.patchedDependencies = patchEntries; - log.debug( - `Added ${Object.keys(copiedPatches).length} patches to isolated package.json`, + manifest.patchedDependencies = Object.fromEntries( + Object.entries(copiedPatches).map(([spec, patchFile]) => [ + spec, + patchFile.path, + ]), ); - } else if ( - packageManager.name !== "pnpm" || - packageManager.majorVersion < 11 - ) { - manifest.pnpm ??= {}; - manifest.pnpm.patchedDependencies = patchEntries; log.debug( `Added ${Object.keys(copiedPatches).length} patches to isolated package.json`, ); + } else if (packageManager.name === "pnpm") { + const { manifest: pnpmPatchPaths } = pnpmPatchOutput; + + if (pnpmPatchPaths) { + manifest.pnpm ??= {}; + manifest.pnpm.patchedDependencies = pnpmPatchPaths; + log.debug( + `Added ${Object.keys(copiedPatches).length} patches to isolated package.json`, + ); + } } } @@ -410,7 +413,7 @@ export function createIsolator(initialConfig?: IsolateConfig) { copiedPatches, }); - if (packageManager.majorVersion >= 11 && hasCopiedPatches) { + if (pnpmPatchOutput.workspace) { log.debug( `Added ${Object.keys(copiedPatches).length} patches to isolated pnpm-workspace.yaml`, ); diff --git a/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts b/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts index 136ca37..cae1ae1 100644 --- a/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts +++ b/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts @@ -30,6 +30,10 @@ import { getPnpmLockfileDir, isRushWorkspace, } from "#/lib/utils"; +import { + getPnpmPatchedDependenciesOutput, + usesPnpmWorkspacePatchedDependencies, +} from "#/lib/patches/pnpm-patched-dependencies"; import { pnpmMapImporter } from "./pnpm-map-importer"; /** @@ -222,10 +226,11 @@ export async function generatePnpmLockfile({ if (useVersion9) { /** * pnpm 11 stores only the patch hash in the lockfile. The matching path - * is written to pnpm-workspace.yaml by writeIsolatePnpmWorkspace. + * is written to the isolate's pnpm workspace configuration. */ const patchWithoutHash = Object.entries(patchedDependencies ?? {}).find( - ([, patchFile]) => majorVersion >= 11 && !patchFile.hash, + ([, patchFile]) => + usesPnpmWorkspacePatchedDependencies(majorVersion) && !patchFile.hash, ); assert( @@ -233,15 +238,11 @@ export async function generatePnpmLockfile({ `Patch ${patchWithoutHash?.[0]} has no lockfile hash`, ); - const lockfilePatchedDependencies = - majorVersion >= 11 && patchedDependencies - ? Object.fromEntries( - Object.entries(patchedDependencies).map(([spec, patchFile]) => [ - spec, - patchFile.hash, - ]), - ) - : patchedDependencies; + const { lockfile: lockfilePatchedDependencies } = + getPnpmPatchedDependenciesOutput({ + majorVersion, + copiedPatches: patchedDependencies, + }); await writeWantedLockfile_v9(isolateDir, { ...prunedLockfile, diff --git a/src/lib/patches/pnpm-patched-dependencies.test.ts b/src/lib/patches/pnpm-patched-dependencies.test.ts new file mode 100644 index 0000000..8acffb8 --- /dev/null +++ b/src/lib/patches/pnpm-patched-dependencies.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import type { PatchFile } from "#/lib/types"; +import { + getPnpmPatchedDependenciesOutput, + usesPnpmWorkspacePatchedDependencies, +} from "./pnpm-patched-dependencies"; + +const copiedPatches: Record = { + "left-pad@1.3.0": { + path: "patches/left-pad@1.3.0.patch", + hash: "sha256-patched", + }, +}; + +describe("usesPnpmWorkspacePatchedDependencies", () => { + it("uses workspace patch paths from pnpm 11 onwards", () => { + expect(usesPnpmWorkspacePatchedDependencies(10)).toBe(false); + expect(usesPnpmWorkspacePatchedDependencies(11)).toBe(true); + expect(usesPnpmWorkspacePatchedDependencies(Number.NaN)).toBe(false); + }); +}); + +describe("getPnpmPatchedDependenciesOutput", () => { + it("returns pnpm 10 lockfile and manifest patch paths", () => { + expect( + getPnpmPatchedDependenciesOutput({ + majorVersion: 10, + copiedPatches, + }), + ).toEqual({ + lockfile: copiedPatches, + manifest: { + "left-pad@1.3.0": "patches/left-pad@1.3.0.patch", + }, + }); + }); + + it("returns pnpm 11 lockfile hashes and workspace patch paths", () => { + expect( + getPnpmPatchedDependenciesOutput({ + majorVersion: 11, + copiedPatches, + }), + ).toEqual({ + lockfile: { "left-pad@1.3.0": "sha256-patched" }, + workspace: { + "left-pad@1.3.0": "patches/left-pad@1.3.0.patch", + }, + }); + }); + + it("does not create patch settings when no patches were copied", () => { + expect(getPnpmPatchedDependenciesOutput({ majorVersion: 11 })).toEqual({}); + }); +}); diff --git a/src/lib/patches/pnpm-patched-dependencies.ts b/src/lib/patches/pnpm-patched-dependencies.ts new file mode 100644 index 0000000..109edbe --- /dev/null +++ b/src/lib/patches/pnpm-patched-dependencies.ts @@ -0,0 +1,57 @@ +import type { PatchFile } from "#/lib/types"; + +type PnpmPatchedDependenciesOutput = { + lockfile?: Record; + manifest?: Record; + workspace?: Record; +}; + +/** Return whether this pnpm version writes patch paths in pnpm-workspace.yaml. */ +export function usesPnpmWorkspacePatchedDependencies(majorVersion: number) { + return Number.isFinite(majorVersion) && majorVersion >= 11; +} + +/** Extract copied patch paths for package-manager configuration. */ +export function getPnpmPatchedDependencyPaths( + copiedPatches: Record, +) { + return Object.fromEntries( + Object.entries(copiedPatches).map(([spec, patchFile]) => [ + spec, + patchFile.path, + ]), + ); +} + +/** + * Convert copied patches into the representations expected by this pnpm + * version. pnpm 11 moved patch paths from package.json and the lockfile to + * pnpm-workspace.yaml, while retaining patch hashes in the lockfile. + */ +export function getPnpmPatchedDependenciesOutput({ + majorVersion, + copiedPatches, +}: { + majorVersion: number; + copiedPatches?: Record; +}): PnpmPatchedDependenciesOutput { + if (!copiedPatches || Object.keys(copiedPatches).length === 0) { + return {}; + } + + const patchPaths = getPnpmPatchedDependencyPaths(copiedPatches); + + if (usesPnpmWorkspacePatchedDependencies(majorVersion)) { + return { + lockfile: Object.fromEntries( + Object.entries(copiedPatches).map(([spec, patchFile]) => [ + spec, + patchFile.hash, + ]), + ), + workspace: patchPaths, + }; + } + + return { lockfile: copiedPatches, manifest: patchPaths }; +} diff --git a/src/lib/patches/write-isolate-pnpm-workspace.test.ts b/src/lib/patches/write-isolate-pnpm-workspace.test.ts index c799dea..9f1f2a5 100644 --- a/src/lib/patches/write-isolate-pnpm-workspace.test.ts +++ b/src/lib/patches/write-isolate-pnpm-workspace.test.ts @@ -164,6 +164,25 @@ describe("writeIsolatePnpmWorkspace", () => { ); }); + it("does not add patch paths to generated pnpm 10 workspaces", () => { + writeGeneratedIsolatePnpmWorkspace({ + isolateDir, + majorVersion: 10, + packages: ["packages/*"], + copiedPatches: { + "lodash@4.17.21": { + path: "patches/lodash@4.17.21.patch", + hash: "sha256-abc123", + }, + }, + }); + + expect(writeTypedYamlSync).toHaveBeenCalledWith( + "/workspace/isolate/pnpm-workspace.yaml", + { packages: ["packages/*"] }, + ); + }); + it("preserves unrelated top-level fields", () => { readTypedYamlSync.mockReturnValue({ packages: ["packages/*"], @@ -235,6 +254,28 @@ describe("writeIsolatePnpmWorkspace", () => { ); }); + it("removes source patch paths from pnpm 11 workspaces when no patches were copied", () => { + readTypedYamlSync.mockReturnValue({ + packages: ["packages/*"], + patchedDependencies: { + "lodash@4.17.21": "patches/lodash@4.17.21.patch", + }, + }); + + writeIsolatePnpmWorkspace({ + workspaceRootDir, + isolateDir, + majorVersion: 11, + copiedPatches: {}, + }); + + expect(fs.copyFileSync).not.toHaveBeenCalled(); + expect(writeTypedYamlSync).toHaveBeenCalledWith( + "/workspace/isolate/pnpm-workspace.yaml", + { packages: ["packages/*"] }, + ); + }); + /** * When patches are being filtered, the rewrite path must still carry * `allowBuilds` into the output yaml — otherwise pnpm 11's build-script @@ -330,4 +371,58 @@ describe("writeIsolatePnpmWorkspace", () => { "/workspace/isolate/pnpm-workspace.yaml", ); }); + + it("writes copied pnpm 11 patch paths when the yaml cannot be parsed", () => { + readTypedYamlSync.mockImplementation(() => { + throw new Error("bad yaml"); + }); + + writeIsolatePnpmWorkspace({ + workspaceRootDir, + isolateDir, + majorVersion: 11, + copiedPatches: { + "lodash@4.17.21": { + path: "patches/lodash@4.17.21.patch", + hash: "sha256-abc123", + }, + }, + }); + + expect(fs.copyFileSync).not.toHaveBeenCalled(); + expect(writeTypedYamlSync).toHaveBeenCalledWith( + "/workspace/isolate/pnpm-workspace.yaml", + { + patchedDependencies: { + "lodash@4.17.21": "patches/lodash@4.17.21.patch", + }, + }, + ); + }); + + it("writes copied pnpm 11 patch paths when the yaml has no settings", () => { + readTypedYamlSync.mockReturnValue(undefined); + + writeIsolatePnpmWorkspace({ + workspaceRootDir, + isolateDir, + majorVersion: 11, + copiedPatches: { + "lodash@4.17.21": { + path: "patches/lodash@4.17.21.patch", + hash: "sha256-abc123", + }, + }, + }); + + expect(fs.copyFileSync).not.toHaveBeenCalled(); + expect(writeTypedYamlSync).toHaveBeenCalledWith( + "/workspace/isolate/pnpm-workspace.yaml", + { + patchedDependencies: { + "lodash@4.17.21": "patches/lodash@4.17.21.patch", + }, + }, + ); + }); }); diff --git a/src/lib/patches/write-isolate-pnpm-workspace.ts b/src/lib/patches/write-isolate-pnpm-workspace.ts index ced1bf7..6d20c22 100644 --- a/src/lib/patches/write-isolate-pnpm-workspace.ts +++ b/src/lib/patches/write-isolate-pnpm-workspace.ts @@ -3,6 +3,10 @@ import path from "node:path"; import { useLogger } from "#/lib/logger"; import type { PatchFile, PnpmSettings } from "#/lib/types"; import { readTypedYamlSync, writeTypedYamlSync } from "#/lib/utils"; +import { + getPnpmPatchedDependenciesOutput, + getPnpmPatchedDependencyPaths, +} from "./pnpm-patched-dependencies"; type GeneratedPnpmWorkspaceSettings = PnpmSettings & { packages: string[]; @@ -19,7 +23,8 @@ type GeneratedPnpmWorkspaceSettings = PnpmSettings & { * and later need copied patch paths. Otherwise, it is copied verbatim to * preserve comments, key order, and trailing whitespace. * - * - The source yaml cannot be read or parsed. + * - The source yaml cannot be read or parsed and pnpm does not need a + * `patchedDependencies` field. * - The parsed settings have no `patchedDependencies` field and pnpm is older * than version 11. * - Every entry in `patchedDependencies` is also present in `copiedPatches` @@ -46,10 +51,25 @@ export function writeIsolatePnpmWorkspace({ const targetPath = path.join(isolateDir, "pnpm-workspace.yaml"); let settings: PnpmSettings | undefined; + const patchOutput = getPnpmPatchedDependenciesOutput({ + majorVersion, + copiedPatches, + }); + const workspacePatchPaths = patchOutput.workspace; try { settings = readTypedYamlSync(sourcePath) as PnpmSettings | undefined; } catch (error) { + if (workspacePatchPaths) { + log.warn( + `Could not read pnpm-workspace.yaml; writing copied patch paths only: ${error instanceof Error ? error.message : String(error)}`, + ); + writeTypedYamlSync(targetPath, { + patchedDependencies: workspacePatchPaths, + }); + return; + } + log.warn( `Could not read pnpm-workspace.yaml, falling back to verbatim copy: ${error instanceof Error ? error.message : String(error)}`, ); @@ -58,34 +78,38 @@ export function writeIsolatePnpmWorkspace({ } if (!settings) { + if (workspacePatchPaths) { + writeTypedYamlSync(targetPath, { + patchedDependencies: workspacePatchPaths, + }); + return; + } + fs.copyFileSync(sourcePath, targetPath); return; } - const needsPnpm11PatchPaths = - majorVersion >= 11 && Object.keys(copiedPatches).length > 0; - - if (!settings.patchedDependencies && !needsPnpm11PatchPaths) { + if (!settings.patchedDependencies && !workspacePatchPaths) { fs.copyFileSync(sourcePath, targetPath); return; } /** - * If every patch declared in the source yaml was kept, copy verbatim so - * comments, ordering, and trailing whitespace are preserved. + * If every patch declared in the source yaml was kept and pnpm does not + * need workspace patch paths, copy verbatim to preserve comments, ordering, + * and trailing whitespace. */ const sourceSpecs = Object.keys(settings.patchedDependencies ?? {}); const copiedSpecs = new Set(Object.keys(copiedPatches)); const hasExclusions = sourceSpecs.some((spec) => !copiedSpecs.has(spec)); - if (!hasExclusions && !needsPnpm11PatchPaths) { + if (!hasExclusions && !workspacePatchPaths) { fs.copyFileSync(sourcePath, targetPath); return; } - const filteredEntries = Object.entries(copiedPatches).map( - ([spec, patchFile]) => [spec, patchFile.path] as const, - ); + const patchPaths = getPnpmPatchedDependencyPaths(copiedPatches); + const filteredEntries = Object.entries(patchPaths); if (filteredEntries.length > 0) { settings.patchedDependencies = Object.fromEntries(filteredEntries); @@ -109,14 +133,13 @@ export function writeGeneratedIsolatePnpmWorkspace({ copiedPatches: Record; }) { const settings: GeneratedPnpmWorkspaceSettings = { packages }; + const patchOutput = getPnpmPatchedDependenciesOutput({ + majorVersion, + copiedPatches, + }); - if (majorVersion >= 11 && Object.keys(copiedPatches).length > 0) { - settings.patchedDependencies = Object.fromEntries( - Object.entries(copiedPatches).map(([spec, patchFile]) => [ - spec, - patchFile.path, - ]), - ); + if (patchOutput.workspace) { + settings.patchedDependencies = patchOutput.workspace; } writeTypedYamlSync(path.join(isolateDir, "pnpm-workspace.yaml"), settings); From 283b5b76630d14621dc30a116ad7a5d191af870c Mon Sep 17 00:00:00 2001 From: Thijs Koerselman Date: Sat, 29 Aug 2026 11:16:59 +0200 Subject: [PATCH 4/8] Address final-review findings (validate patch metadata) --- src/isolate.integration.test.ts | 199 ++++++++++-------- src/isolate.ts | 10 +- src/lib/patches/copy-patches.test.ts | 45 ++++ src/lib/patches/copy-patches.ts | 31 ++- .../write-isolate-pnpm-workspace.test.ts | 131 ++++++++---- .../patches/write-isolate-pnpm-workspace.ts | 34 +-- 6 files changed, 302 insertions(+), 148 deletions(-) diff --git a/src/isolate.integration.test.ts b/src/isolate.integration.test.ts index 465bf29..ab4b7ae 100644 --- a/src/isolate.integration.test.ts +++ b/src/isolate.integration.test.ts @@ -67,93 +67,120 @@ describe("isolate integration", () => { ).resolves.toMatchObject({ name: "functions-node", version: "1.0.0" }); }); - it("writes pnpm 11 patch settings to the lockfile and workspace configuration", async () => { - const workspaceRoot = await fs.mkdtemp( - path.join(os.tmpdir(), "isolate-pnpm-patches-test-"), - ); - temporaryDirectories.push(workspaceRoot); - const targetPackageDir = path.join(workspaceRoot, "packages", "functions"); - await fs.ensureDir(targetPackageDir); - await fs.ensureDir(path.join(workspaceRoot, "patches")); - await fs.writeJson(path.join(workspaceRoot, "package.json"), { - name: "workspace", - version: "1.0.0", - private: true, - packageManager: "pnpm@11.0.0", - }); - await fs.writeFile( - path.join(workspaceRoot, "pnpm-workspace.yaml"), - [ - "packages:", - " - packages/*", - "patchedDependencies:", - " left-pad@1.3.0: patches/left-pad@1.3.0.patch", - "", - ].join("\n"), - ); - await fs.writeFile( - path.join(workspaceRoot, "pnpm-lock.yaml"), - [ - "lockfileVersion: '9.0'", - "", - "settings:", - " autoInstallPeers: false", - "", - "importers:", - "", - " packages/functions:", - " dependencies:", - " left-pad:", - " specifier: 1.3.0", - " version: 1.3.0", - "", - "packages:", - "", - " left-pad@1.3.0:", - " resolution: {integrity: sha512-1}", - "", - "snapshots:", - "", - " left-pad@1.3.0: {}", - "", - "patchedDependencies:", - " left-pad@1.3.0: sha256-patched", - "", - ].join("\n"), - ); - await fs.writeFile( - path.join(workspaceRoot, "patches", "left-pad@1.3.0.patch"), - "diff --git a/index.js b/index.js\n", - ); - await fs.writeJson(path.join(targetPackageDir, "package.json"), { - name: "functions", - version: "1.0.0", - main: "./index.js", - files: ["index.js"], - dependencies: { "left-pad": "1.3.0" }, - }); - await fs.writeFile(path.join(targetPackageDir, "index.js"), "export {};\n"); + it.each([10, 11])( + "writes pnpm %i patch settings to the lockfile and workspace configuration", + async (majorVersion) => { + const workspaceRoot = await fs.mkdtemp( + path.join(os.tmpdir(), "isolate-pnpm-patches-test-"), + ); + temporaryDirectories.push(workspaceRoot); + const targetPackageDir = path.join( + workspaceRoot, + "packages", + "functions", + ); + await fs.ensureDir(targetPackageDir); + await fs.ensureDir(path.join(workspaceRoot, "patches")); + await fs.writeJson(path.join(workspaceRoot, "package.json"), { + name: "workspace", + version: "1.0.0", + private: true, + packageManager: `pnpm@${majorVersion}.0.0`, + }); + await fs.writeFile( + path.join(workspaceRoot, "pnpm-workspace.yaml"), + [ + "packages:", + " - packages/*", + "patchedDependencies:", + " left-pad@1.3.0: patches/left-pad@1.3.0.patch", + "", + ].join("\n"), + ); + await fs.writeFile( + path.join(workspaceRoot, "pnpm-lock.yaml"), + [ + "lockfileVersion: '9.0'", + "", + "settings:", + " autoInstallPeers: false", + "", + "importers:", + "", + " packages/functions:", + " dependencies:", + " left-pad:", + " specifier: 1.3.0", + " version: 1.3.0", + "", + "packages:", + "", + " left-pad@1.3.0:", + " resolution: {integrity: sha512-1}", + "", + "snapshots:", + "", + " left-pad@1.3.0: {}", + "", + "patchedDependencies:", + ...(majorVersion >= 11 + ? [" left-pad@1.3.0: sha256-patched"] + : [ + " left-pad@1.3.0:", + " path: patches/left-pad@1.3.0.patch", + " hash: sha256-patched", + ]), + "", + ].join("\n"), + ); + await fs.writeFile( + path.join(workspaceRoot, "patches", "left-pad@1.3.0.patch"), + "diff --git a/index.js b/index.js\n", + ); + await fs.writeJson(path.join(targetPackageDir, "package.json"), { + name: "functions", + version: "1.0.0", + main: "./index.js", + files: ["index.js"], + dependencies: { "left-pad": "1.3.0" }, + }); + await fs.writeFile( + path.join(targetPackageDir, "index.js"), + "export {};\n", + ); - const isolateDir = await isolate({ - targetPackagePath: targetPackageDir, - buildDirName: ".", - workspaceRoot: "../..", - }); + const isolateDir = await isolate({ + targetPackagePath: targetPackageDir, + buildDirName: ".", + workspaceRoot: "../..", + }); - const manifest = await fs.readJson(path.join(isolateDir, "package.json")); - const workspaceSettings = parse( - await fs.readFile(path.join(isolateDir, "pnpm-workspace.yaml"), "utf8"), - ); - const lockfile = parse( - await fs.readFile(path.join(isolateDir, "pnpm-lock.yaml"), "utf8"), - ); + const manifest = await fs.readJson(path.join(isolateDir, "package.json")); + const workspaceSettings = parse( + await fs.readFile(path.join(isolateDir, "pnpm-workspace.yaml"), "utf8"), + ); + const lockfile = parse( + await fs.readFile(path.join(isolateDir, "pnpm-lock.yaml"), "utf8"), + ); - expect(manifest.pnpm?.patchedDependencies).toBeUndefined(); - expect(workspaceSettings.patchedDependencies).toEqual({ - "left-pad@1.3.0": "patches/left-pad@1.3.0.patch", - }); - expect(lockfile.patchedDependencies).toEqual({ - "left-pad@1.3.0": "sha256-patched", - }); - }); + expect(manifest.pnpm?.patchedDependencies).toEqual( + majorVersion >= 11 + ? undefined + : { "left-pad@1.3.0": "patches/left-pad@1.3.0.patch" }, + ); + expect(workspaceSettings.patchedDependencies).toEqual({ + "left-pad@1.3.0": "patches/left-pad@1.3.0.patch", + }); + expect(lockfile.patchedDependencies).toEqual( + majorVersion >= 11 + ? { "left-pad@1.3.0": "sha256-patched" } + : { + "left-pad@1.3.0": { + path: "patches/left-pad@1.3.0.patch", + hash: "sha256-patched", + }, + }, + ); + }, + ); }); diff --git a/src/isolate.ts b/src/isolate.ts index 9bf4207..e3c0106 100644 --- a/src/isolate.ts +++ b/src/isolate.ts @@ -412,12 +412,12 @@ export function createIsolator(initialConfig?: IsolateConfig) { majorVersion: packageManager.majorVersion, copiedPatches, }); + } - if (pnpmPatchOutput.workspace) { - log.debug( - `Added ${Object.keys(copiedPatches).length} patches to isolated pnpm-workspace.yaml`, - ); - } + if (pnpmPatchOutput.workspace) { + log.debug( + `Added ${Object.keys(copiedPatches).length} patches to isolated pnpm-workspace.yaml`, + ); } } diff --git a/src/lib/patches/copy-patches.test.ts b/src/lib/patches/copy-patches.test.ts index b321d07..4b83c60 100644 --- a/src/lib/patches/copy-patches.test.ts +++ b/src/lib/patches/copy-patches.test.ts @@ -724,6 +724,51 @@ describe("copyPatches", () => { }); }); + it("rejects pnpm 11 patches without a lockfile hash before copying files", async () => { + const targetManifest: PackageManifest = { + name: "test", + version: "1.0.0", + dependencies: { lodash: "^4.0.0" }, + }; + + readTypedYamlSync.mockReturnValue({ + patchedDependencies: { + "lodash@4.17.21": "patches/lodash.patch", + }, + }); + readTypedJson.mockResolvedValue({ + name: "root", + version: "1.0.0", + } as PackageManifest); + filterPatchedDependencies.mockReturnValue({ + "lodash@4.17.21": "patches/lodash.patch", + }); + fs.existsSync.mockReturnValue(true); + usePackageManager.mockReturnValue({ + name: "pnpm", + majorVersion: 11, + version: "11.0.0", + packageManagerString: "pnpm@11.0.0", + }); + readWantedLockfile_v9.mockResolvedValue({ + lockfileVersion: "9.0", + } as unknown as Awaited>); + + await expect( + copyPatches({ + workspaceRootDir: "/workspace", + targetPackageDir: "/workspace/packages/test", + internalDepPackageNames: [], + targetPackageManifest: targetManifest, + isolateDir: "/workspace/isolate", + packagesRegistry: {}, + includeDevDependencies: false, + }), + ).rejects.toThrow("No hash found for patch lodash@4.17.21 in lockfile"); + + expect(fs.copy).not.toHaveBeenCalled(); + }); + it("should read the patch hash from the pnpm <=10 object lockfile format (regression: issue #201)", async () => { const targetManifest: PackageManifest = { name: "test", diff --git a/src/lib/patches/copy-patches.ts b/src/lib/patches/copy-patches.ts index e73b5a6..004708d 100644 --- a/src/lib/patches/copy-patches.ts +++ b/src/lib/patches/copy-patches.ts @@ -20,6 +20,7 @@ import { } from "#/lib/utils"; import { collectInstalledNamesFromBunLockfile } from "./collect-installed-names-bun"; import { collectInstalledNamesFromPnpmLockfile } from "./collect-installed-names-pnpm"; +import { usesPnpmWorkspacePatchedDependencies } from "./pnpm-patched-dependencies"; export async function copyPatches({ workspaceRootDir, @@ -159,6 +160,11 @@ export async function copyPatches({ ? await readLockfilePatchedDependencies(workspaceRootDir) : undefined; + const patchFilesToCopy: { + packageSpec: string; + sourcePath: string; + targetPath: string; + }[] = []; const copiedPatches: Record = {}; for (const [packageSpec, patchPath] of Object.entries(filteredPatches)) { @@ -171,12 +177,6 @@ export async function copyPatches({ continue; } - /** Preserve original folder structure */ - const targetPatchPath = path.join(isolateDir, patchPath); - await fs.ensureDir(path.dirname(targetPatchPath)); - await fs.copy(sourcePatchPath, targetPatchPath); - log.debug(`Copied patch for ${packageSpec}: ${patchPath}`); - /** * Get the hash from the original lockfile, or use empty string if not * found. pnpm 11 simplified the lockfile `patchedDependencies` format from @@ -189,6 +189,14 @@ export async function copyPatches({ ? originalPatchFile : (originalPatchFile?.hash ?? ""); + if ( + packageManagerName === "pnpm" && + usesPnpmWorkspacePatchedDependencies(majorVersion) && + !hash + ) { + throw new Error(`No hash found for patch ${packageSpec} in lockfile`); + } + if (packageManagerName === "pnpm" && !hash) { log.warn(`No hash found for patch ${packageSpec} in lockfile`); } @@ -197,6 +205,17 @@ export async function copyPatches({ path: patchPath, hash, }; + patchFilesToCopy.push({ + packageSpec, + sourcePath: sourcePatchPath, + targetPath: path.join(isolateDir, patchPath), + }); + } + + for (const patchFile of patchFilesToCopy) { + await fs.ensureDir(path.dirname(patchFile.targetPath)); + await fs.copy(patchFile.sourcePath, patchFile.targetPath); + log.debug(`Copied patch for ${patchFile.packageSpec}`); } if (Object.keys(copiedPatches).length > 0) { diff --git a/src/lib/patches/write-isolate-pnpm-workspace.test.ts b/src/lib/patches/write-isolate-pnpm-workspace.test.ts index 9f1f2a5..b7f0438 100644 --- a/src/lib/patches/write-isolate-pnpm-workspace.test.ts +++ b/src/lib/patches/write-isolate-pnpm-workspace.test.ts @@ -257,6 +257,8 @@ describe("writeIsolatePnpmWorkspace", () => { it("removes source patch paths from pnpm 11 workspaces when no patches were copied", () => { readTypedYamlSync.mockReturnValue({ packages: ["packages/*"], + allowBuilds: { esbuild: true }, + minimumReleaseAge: 10_080, patchedDependencies: { "lodash@4.17.21": "patches/lodash@4.17.21.patch", }, @@ -272,7 +274,45 @@ describe("writeIsolatePnpmWorkspace", () => { expect(fs.copyFileSync).not.toHaveBeenCalled(); expect(writeTypedYamlSync).toHaveBeenCalledWith( "/workspace/isolate/pnpm-workspace.yaml", - { packages: ["packages/*"] }, + { + packages: ["packages/*"], + allowBuilds: { esbuild: true }, + minimumReleaseAge: 10_080, + }, + ); + }); + + it("rewrites pnpm 11 paths when every source patch was copied", () => { + readTypedYamlSync.mockReturnValue({ + packages: ["packages/*"], + allowBuilds: { esbuild: true }, + patchedDependencies: { + "lodash@4.17.21": "patches/lodash@4.17.21.patch", + }, + }); + + writeIsolatePnpmWorkspace({ + workspaceRootDir, + isolateDir, + majorVersion: 11, + copiedPatches: { + "lodash@4.17.21": { + path: "patches/lodash@4.17.21.patch", + hash: "sha256-abc123", + }, + }, + }); + + expect(fs.copyFileSync).not.toHaveBeenCalled(); + expect(writeTypedYamlSync).toHaveBeenCalledWith( + "/workspace/isolate/pnpm-workspace.yaml", + { + packages: ["packages/*"], + allowBuilds: { esbuild: true }, + patchedDependencies: { + "lodash@4.17.21": "patches/lodash@4.17.21.patch", + }, + }, ); }); @@ -372,57 +412,74 @@ describe("writeIsolatePnpmWorkspace", () => { ); }); - it("writes copied pnpm 11 patch paths when the yaml cannot be parsed", () => { + it("rejects pnpm 11 patch output when the yaml cannot be parsed", () => { readTypedYamlSync.mockImplementation(() => { throw new Error("bad yaml"); }); - writeIsolatePnpmWorkspace({ - workspaceRootDir, - isolateDir, - majorVersion: 11, - copiedPatches: { - "lodash@4.17.21": { - path: "patches/lodash@4.17.21.patch", - hash: "sha256-abc123", + expect(() => + writeIsolatePnpmWorkspace({ + workspaceRootDir, + isolateDir, + majorVersion: 11, + copiedPatches: { + "lodash@4.17.21": { + path: "patches/lodash@4.17.21.patch", + hash: "sha256-abc123", + }, }, - }, - }); + }), + ).toThrow( + "Cannot write pnpm 11 patch paths without readable workspace settings", + ); expect(fs.copyFileSync).not.toHaveBeenCalled(); - expect(writeTypedYamlSync).toHaveBeenCalledWith( - "/workspace/isolate/pnpm-workspace.yaml", - { - patchedDependencies: { - "lodash@4.17.21": "patches/lodash@4.17.21.patch", - }, - }, - ); + expect(writeTypedYamlSync).not.toHaveBeenCalled(); }); - it("writes copied pnpm 11 patch paths when the yaml has no settings", () => { + it("rejects pnpm 11 patch output when the yaml has no settings", () => { readTypedYamlSync.mockReturnValue(undefined); - writeIsolatePnpmWorkspace({ - workspaceRootDir, - isolateDir, - majorVersion: 11, - copiedPatches: { - "lodash@4.17.21": { - path: "patches/lodash@4.17.21.patch", - hash: "sha256-abc123", + expect(() => + writeIsolatePnpmWorkspace({ + workspaceRootDir, + isolateDir, + majorVersion: 11, + copiedPatches: { + "lodash@4.17.21": { + path: "patches/lodash@4.17.21.patch", + hash: "sha256-abc123", + }, }, - }, - }); + }), + ).toThrow( + "Cannot write pnpm 11 patch paths without readable workspace settings", + ); expect(fs.copyFileSync).not.toHaveBeenCalled(); - expect(writeTypedYamlSync).toHaveBeenCalledWith( - "/workspace/isolate/pnpm-workspace.yaml", - { - patchedDependencies: { - "lodash@4.17.21": "patches/lodash@4.17.21.patch", + expect(writeTypedYamlSync).not.toHaveBeenCalled(); + }); + + it("rejects pnpm 11 patch output when the yaml contains a scalar", () => { + readTypedYamlSync.mockReturnValue("not workspace settings"); + + expect(() => + writeIsolatePnpmWorkspace({ + workspaceRootDir, + isolateDir, + majorVersion: 11, + copiedPatches: { + "lodash@4.17.21": { + path: "patches/lodash@4.17.21.patch", + hash: "sha256-abc123", + }, }, - }, + }), + ).toThrow( + "Cannot write pnpm 11 patch paths without readable workspace settings", ); + + expect(fs.copyFileSync).not.toHaveBeenCalled(); + expect(writeTypedYamlSync).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/patches/write-isolate-pnpm-workspace.ts b/src/lib/patches/write-isolate-pnpm-workspace.ts index 6d20c22..f758d46 100644 --- a/src/lib/patches/write-isolate-pnpm-workspace.ts +++ b/src/lib/patches/write-isolate-pnpm-workspace.ts @@ -24,7 +24,8 @@ type GeneratedPnpmWorkspaceSettings = PnpmSettings & { * preserve comments, key order, and trailing whitespace. * * - The source yaml cannot be read or parsed and pnpm does not need a - * `patchedDependencies` field. + * `patchedDependencies` field. pnpm 11 and later instead fail clearly when + * copied patches require adapted paths in that file. * - The parsed settings have no `patchedDependencies` field and pnpm is older * than version 11. * - Every entry in `patchedDependencies` is also present in `copiedPatches` @@ -50,7 +51,7 @@ export function writeIsolatePnpmWorkspace({ const sourcePath = path.join(workspaceRootDir, "pnpm-workspace.yaml"); const targetPath = path.join(isolateDir, "pnpm-workspace.yaml"); - let settings: PnpmSettings | undefined; + let settings: unknown; const patchOutput = getPnpmPatchedDependenciesOutput({ majorVersion, copiedPatches, @@ -58,16 +59,13 @@ export function writeIsolatePnpmWorkspace({ const workspacePatchPaths = patchOutput.workspace; try { - settings = readTypedYamlSync(sourcePath) as PnpmSettings | undefined; + settings = readTypedYamlSync(sourcePath); } catch (error) { if (workspacePatchPaths) { - log.warn( - `Could not read pnpm-workspace.yaml; writing copied patch paths only: ${error instanceof Error ? error.message : String(error)}`, + throw new Error( + "Cannot write pnpm 11 patch paths without readable workspace settings", + { cause: error }, ); - writeTypedYamlSync(targetPath, { - patchedDependencies: workspacePatchPaths, - }); - return; } log.warn( @@ -77,12 +75,11 @@ export function writeIsolatePnpmWorkspace({ return; } - if (!settings) { + if (!isPnpmWorkspaceSettings(settings)) { if (workspacePatchPaths) { - writeTypedYamlSync(targetPath, { - patchedDependencies: workspacePatchPaths, - }); - return; + throw new Error( + "Cannot write pnpm 11 patch paths without readable workspace settings", + ); } fs.copyFileSync(sourcePath, targetPath); @@ -120,6 +117,15 @@ export function writeIsolatePnpmWorkspace({ writeTypedYamlSync(targetPath, settings); } +/** Narrow parsed YAML to a mapping that can hold pnpm workspace settings. */ +function isPnpmWorkspaceSettings(settings: unknown): settings is PnpmSettings { + return ( + typeof settings === "object" && + settings !== null && + !Array.isArray(settings) + ); +} + /** Write the workspace configuration generated for a Rush isolate. */ export function writeGeneratedIsolatePnpmWorkspace({ isolateDir, From 129e0b4edc0e7ac417a3d6f7baaa2fc3d7cddf90 Mon Sep 17 00:00:00 2001 From: Thijs Koerselman Date: Sat, 29 Aug 2026 11:35:24 +0200 Subject: [PATCH 5/8] Remove legacy pnpm patch metadata from pnpm 11 output --- src/isolate.integration.test.ts | 5 +++++ src/isolate.ts | 23 +++++++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/isolate.integration.test.ts b/src/isolate.integration.test.ts index ab4b7ae..f11266b 100644 --- a/src/isolate.integration.test.ts +++ b/src/isolate.integration.test.ts @@ -143,6 +143,11 @@ describe("isolate integration", () => { main: "./index.js", files: ["index.js"], dependencies: { "left-pad": "1.3.0" }, + pnpm: { + patchedDependencies: { + "left-pad@1.3.0": "patches/stale-left-pad.patch", + }, + }, }); await fs.writeFile( path.join(targetPackageDir, "index.js"), diff --git a/src/isolate.ts b/src/isolate.ts index e3c0106..000b91b 100644 --- a/src/isolate.ts +++ b/src/isolate.ts @@ -28,7 +28,10 @@ import { import { detectPackageManager, shouldUsePnpmPack } from "./lib/package-manager"; import { getVersion } from "./lib/package-manager/helpers/infer-from-files"; import { copyPatches } from "./lib/patches/copy-patches"; -import { getPnpmPatchedDependenciesOutput } from "./lib/patches/pnpm-patched-dependencies"; +import { + getPnpmPatchedDependenciesOutput, + usesPnpmWorkspacePatchedDependencies, +} from "./lib/patches/pnpm-patched-dependencies"; import { writeGeneratedIsolatePnpmWorkspace, writeIsolatePnpmWorkspace, @@ -337,11 +340,23 @@ export function createIsolator(initialConfig?: IsolateConfig) { majorVersion: packageManager.majorVersion, copiedPatches, }); - - /** Update the manifest for copied patches when its format carries them or for an npm fallback. */ - if (hasCopiedPatches || usedFallbackToNpm) { + const shouldRemoveLegacyPnpmPatchedDependencies = + packageManager.name === "pnpm" && + !config.forceNpm && + usesPnpmWorkspacePatchedDependencies(packageManager.majorVersion); + + /** Update patch metadata for copied patches, pnpm 11 workspace output, or an npm fallback. */ + if ( + hasCopiedPatches || + usedFallbackToNpm || + shouldRemoveLegacyPnpmPatchedDependencies + ) { const manifest = await readManifest(isolateDir); + if (shouldRemoveLegacyPnpmPatchedDependencies) { + delete manifest.pnpm?.patchedDependencies; + } + if (hasCopiedPatches) { if (packageManager.name === "bun") { manifest.patchedDependencies = Object.fromEntries( From a8afdb5af04f93ce14db7d11bc54518fb5409f4c Mon Sep 17 00:00:00 2001 From: Thijs Koerselman Date: Sat, 29 Aug 2026 11:50:58 +0200 Subject: [PATCH 6/8] Strengthen patched pnpm workspace coverage --- src/isolate.integration.test.ts | 18 ++- .../workspace/package.json | 10 ++ .../workspace/packages/functions/package.json | 10 ++ .../workspace/patches/left-pad@1.3.0.patch | 14 ++ .../workspace/pnpm-lock.yaml | 31 ++++ .../workspace/pnpm-workspace.yaml | 2 + .../workspace/package.json | 5 + .../workspace/packages/functions/package.json | 10 ++ .../workspace/patches/left-pad@1.3.0.patch | 14 ++ .../workspace/pnpm-lock.yaml | 149 ++++++++++++++++++ .../workspace/pnpm-workspace.yaml | 4 + ...generate-pnpm-lockfile.integration.test.ts | 51 +++++- src/lib/patches/copy-patches.test.ts | 10 +- 13 files changed, 319 insertions(+), 9 deletions(-) create mode 100644 src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-10/workspace/package.json create mode 100644 src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-10/workspace/packages/functions/package.json create mode 100644 src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-10/workspace/patches/left-pad@1.3.0.patch create mode 100644 src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-10/workspace/pnpm-lock.yaml create mode 100644 src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-10/workspace/pnpm-workspace.yaml create mode 100644 src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-12/workspace/package.json create mode 100644 src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-12/workspace/packages/functions/package.json create mode 100644 src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-12/workspace/patches/left-pad@1.3.0.patch create mode 100644 src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-12/workspace/pnpm-lock.yaml create mode 100644 src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-12/workspace/pnpm-workspace.yaml diff --git a/src/isolate.integration.test.ts b/src/isolate.integration.test.ts index f11266b..2d8606c 100644 --- a/src/isolate.integration.test.ts +++ b/src/isolate.integration.test.ts @@ -1,4 +1,5 @@ import fs from "fs-extra"; +import { createHash } from "node:crypto"; import os from "node:os"; import path from "node:path"; import { parse } from "yaml"; @@ -7,6 +8,9 @@ import { isolate } from "./isolate"; describe("isolate integration", () => { const temporaryDirectories: string[] = []; + const leftPadPatchContents = "diff --git a/index.js b/index.js\n"; + const leftPadPatchHash = + "2692094a267de7e28825147fd6cb2ebde098a4e68c25dfa3976ac806f4a1a784"; afterEach(async () => { await Promise.all( @@ -124,18 +128,18 @@ describe("isolate integration", () => { "", "patchedDependencies:", ...(majorVersion >= 11 - ? [" left-pad@1.3.0: sha256-patched"] + ? [` left-pad@1.3.0: ${leftPadPatchHash}`] : [ " left-pad@1.3.0:", " path: patches/left-pad@1.3.0.patch", - " hash: sha256-patched", + ` hash: ${leftPadPatchHash}`, ]), "", ].join("\n"), ); await fs.writeFile( path.join(workspaceRoot, "patches", "left-pad@1.3.0.patch"), - "diff --git a/index.js b/index.js\n", + leftPadPatchContents, ); await fs.writeJson(path.join(targetPackageDir, "package.json"), { name: "functions", @@ -154,6 +158,10 @@ describe("isolate integration", () => { "export {};\n", ); + expect( + createHash("sha256").update(leftPadPatchContents).digest("hex"), + ).toBe(leftPadPatchHash); + const isolateDir = await isolate({ targetPackagePath: targetPackageDir, buildDirName: ".", @@ -178,11 +186,11 @@ describe("isolate integration", () => { }); expect(lockfile.patchedDependencies).toEqual( majorVersion >= 11 - ? { "left-pad@1.3.0": "sha256-patched" } + ? { "left-pad@1.3.0": leftPadPatchHash } : { "left-pad@1.3.0": { path: "patches/left-pad@1.3.0.patch", - hash: "sha256-patched", + hash: leftPadPatchHash, }, }, ); diff --git a/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-10/workspace/package.json b/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-10/workspace/package.json new file mode 100644 index 0000000..6469e31 --- /dev/null +++ b/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-10/workspace/package.json @@ -0,0 +1,10 @@ +{ + "name": "patch-validation-workspace", + "private": true, + "packageManager": "pnpm@10.0.0", + "pnpm": { + "patchedDependencies": { + "left-pad@1.3.0": "patches/left-pad@1.3.0.patch" + } + } +} diff --git a/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-10/workspace/packages/functions/package.json b/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-10/workspace/packages/functions/package.json new file mode 100644 index 0000000..86561b8 --- /dev/null +++ b/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-10/workspace/packages/functions/package.json @@ -0,0 +1,10 @@ +{ + "name": "functions", + "version": "1.0.0", + "files": [ + "index.js" + ], + "dependencies": { + "left-pad": "1.3.0" + } +} diff --git a/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-10/workspace/patches/left-pad@1.3.0.patch b/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-10/workspace/patches/left-pad@1.3.0.patch new file mode 100644 index 0000000..7b3d767 --- /dev/null +++ b/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-10/workspace/patches/left-pad@1.3.0.patch @@ -0,0 +1,14 @@ +diff --git a/index.js b/index.js +index e90aec35d979c42dcd4ddfacb4768c00d7102349..ba97d1bf59b5cf6de4ddbdcd49a7004f0c7715e5 100644 +--- a/index.js ++++ b/index.js +@@ -4,7 +4,7 @@ + * To Public License, Version 2, as published by Sam Hocevar. See + * http://www.wtfpl.net/ for more details. */ + 'use strict'; +-module.exports = leftPad; ++module.exports = (str, len, ch) => leftPad(str, len, ch); + + var cache = [ + '', + diff --git a/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-10/workspace/pnpm-lock.yaml b/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-10/workspace/pnpm-lock.yaml new file mode 100644 index 0000000..d8c8776 --- /dev/null +++ b/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-10/workspace/pnpm-lock.yaml @@ -0,0 +1,31 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +patchedDependencies: + left-pad@1.3.0: + hash: 9e0f13b98377d5c4c2e7b7b6ba81f2619588134e4df976f8b79cc200b02cf500 + path: patches/left-pad@1.3.0.patch + +importers: + + .: {} + + packages/functions: + dependencies: + left-pad: + specifier: 1.3.0 + version: 1.3.0(patch_hash=9e0f13b98377d5c4c2e7b7b6ba81f2619588134e4df976f8b79cc200b02cf500) + +packages: + + left-pad@1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + deprecated: use String.prototype.padStart() + +snapshots: + + left-pad@1.3.0(patch_hash=9e0f13b98377d5c4c2e7b7b6ba81f2619588134e4df976f8b79cc200b02cf500): {} + diff --git a/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-10/workspace/pnpm-workspace.yaml b/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-10/workspace/pnpm-workspace.yaml new file mode 100644 index 0000000..924b55f --- /dev/null +++ b/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-10/workspace/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-12/workspace/package.json b/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-12/workspace/package.json new file mode 100644 index 0000000..4907cdb --- /dev/null +++ b/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-12/workspace/package.json @@ -0,0 +1,5 @@ +{ + "name": "patch-validation-workspace", + "private": true, + "packageManager": "pnpm@12.0.0" +} diff --git a/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-12/workspace/packages/functions/package.json b/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-12/workspace/packages/functions/package.json new file mode 100644 index 0000000..86561b8 --- /dev/null +++ b/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-12/workspace/packages/functions/package.json @@ -0,0 +1,10 @@ +{ + "name": "functions", + "version": "1.0.0", + "files": [ + "index.js" + ], + "dependencies": { + "left-pad": "1.3.0" + } +} diff --git a/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-12/workspace/patches/left-pad@1.3.0.patch b/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-12/workspace/patches/left-pad@1.3.0.patch new file mode 100644 index 0000000..7b3d767 --- /dev/null +++ b/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-12/workspace/patches/left-pad@1.3.0.patch @@ -0,0 +1,14 @@ +diff --git a/index.js b/index.js +index e90aec35d979c42dcd4ddfacb4768c00d7102349..ba97d1bf59b5cf6de4ddbdcd49a7004f0c7715e5 100644 +--- a/index.js ++++ b/index.js +@@ -4,7 +4,7 @@ + * To Public License, Version 2, as published by Sam Hocevar. See + * http://www.wtfpl.net/ for more details. */ + 'use strict'; +-module.exports = leftPad; ++module.exports = (str, len, ch) => leftPad(str, len, ch); + + var cache = [ + '', + diff --git a/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-12/workspace/pnpm-lock.yaml b/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-12/workspace/pnpm-lock.yaml new file mode 100644 index 0000000..ebcf2ca --- /dev/null +++ b/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-12/workspace/pnpm-lock.yaml @@ -0,0 +1,149 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + '@pnpm/exe': + specifier: 12.0.0 + version: 12.0.0 + pnpm: + specifier: 12.0.0 + version: 12.0.0 + +packages: + + '@pnpm/exe.darwin-arm64@12.0.0': + resolution: {integrity: sha512-sqeoPfVMIfQhbwzDrKraXY2ynyuWClFqzvfImzAS/yczEru1m5SGvQ9kgFPDvQzJZ9AetedgJeDZC6qYvH8/tQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.0.0': + resolution: {integrity: sha512-Quc3J6c9cGTy+LDgz1cLVgCNOU9IERuyAlDoEj0DCilKqvo50Jx1GV8k74iwn4J9fFSKkm8JrwNvtTDj3uWnUA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.0.0': + resolution: {integrity: sha512-EVWd3OTmgsMFhXx69b5JxIzoabG9Ma7m4OeTaf0ZKBzMnfYi8u21NDQo92ToMrdYL5dYDDCHsyYIjXzk+d0HhA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.0.0': + resolution: {integrity: sha512-cXHHW8M4rAPsYNkKZO9WVcpLLK55i9EaIsZPfIqUuY2eopd5LqnFyBge54HCh1GC0yCX8ySn0hYIi+4OyAEoDg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.0.0': + resolution: {integrity: sha512-UcXwMdFjly0mpddkGigHKTxe27IMv2fUK4IWW/MHmJ3yMguxXmkwNlEI4aE+G1HO2TLo20uNEUWD4ymLe/DaCQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.0.0': + resolution: {integrity: sha512-6Rsl+zEWMOmus7v7/9J3OE8EMvHyNAfxYmDfmhQG4J0985OuT3G3Ho9NSGHjkBn4aU4bgklWifRhe1HX8dUSyw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.0.0': + resolution: {integrity: sha512-O5F76A4oVFrpDGdFxEszRIThOSBfjHdH5c006gR+7UTCfiXrukr1XfqPungUI1DXcSR5gb9jBsPQqQZOAoOOxw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.0.0': + resolution: {integrity: sha512-5dKFajIEWJ1ai+KHXFJvskY6vchbunmLwSUV2ywbLymcmJjfY5XJVpgzPCyIoVCMVG0zHorr66+hM8h8b3aRfQ==} + cpu: [x64] + os: [win32] + + '@pnpm/exe@12.0.0': + resolution: {integrity: sha512-405vw2qYPPghNxoPRt2cvkGtGNU5gnInDcIe5TztQxe73yUZLqFJN/jCJFGs1xbVaIWDXEVuQ71P8npFZeatZQ==} + engines: {node: '>=18.*'} + hasBin: true + + pnpm@12.0.0: + resolution: {integrity: sha512-ni49w5EZlYaNyUuBdcIXwn6VQI+gO0oidJd48rNPdzt3zdOznt6BcbIvzVO+ajU0Lp+smUimjvWN9kiM6Jp+Zw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.0.0': + optional: true + + '@pnpm/exe.darwin-x64@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-arm64@12.0.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.0.0': + optional: true + + '@pnpm/exe.linux-x64@12.0.0': + optional: true + + '@pnpm/exe.win32-arm64@12.0.0': + optional: true + + '@pnpm/exe.win32-x64@12.0.0': + optional: true + + '@pnpm/exe@12.0.0': + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + + pnpm@12.0.0: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.0.0 + '@pnpm/exe.darwin-x64': 12.0.0 + '@pnpm/exe.linux-arm64': 12.0.0 + '@pnpm/exe.linux-arm64-musl': 12.0.0 + '@pnpm/exe.linux-x64': 12.0.0 + '@pnpm/exe.linux-x64-musl': 12.0.0 + '@pnpm/exe.win32-arm64': 12.0.0 + '@pnpm/exe.win32-x64': 12.0.0 + +--- +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +patchedDependencies: + left-pad@1.3.0: 9e0f13b98377d5c4c2e7b7b6ba81f2619588134e4df976f8b79cc200b02cf500 + +importers: + + .: {} + + packages/functions: + dependencies: + left-pad: + specifier: 1.3.0 + version: 1.3.0(patch_hash=9e0f13b98377d5c4c2e7b7b6ba81f2619588134e4df976f8b79cc200b02cf500) + +packages: + + left-pad@1.3.0: + resolution: {integrity: sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==} + deprecated: use String.prototype.padStart() + +snapshots: + + left-pad@1.3.0(patch_hash=9e0f13b98377d5c4c2e7b7b6ba81f2619588134e4df976f8b79cc200b02cf500): {} + diff --git a/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-12/workspace/pnpm-workspace.yaml b/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-12/workspace/pnpm-workspace.yaml new file mode 100644 index 0000000..89294f2 --- /dev/null +++ b/src/lib/lockfile/helpers/__fixtures__/pnpm-patched-dependencies-12/workspace/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +packages: + - packages/* +patchedDependencies: + left-pad@1.3.0: patches/left-pad@1.3.0.patch diff --git a/src/lib/lockfile/helpers/generate-pnpm-lockfile.integration.test.ts b/src/lib/lockfile/helpers/generate-pnpm-lockfile.integration.test.ts index 603e4dc..ec98ba8 100644 --- a/src/lib/lockfile/helpers/generate-pnpm-lockfile.integration.test.ts +++ b/src/lib/lockfile/helpers/generate-pnpm-lockfile.integration.test.ts @@ -83,6 +83,8 @@ describe("generatePnpmLockfile integration", () => { fixture = "pnpm-two-document", mutateLockfile, majorVersion = 12, + targetPackageName = "svc", + targetPackageRelativePath = "apps/svc", patchedDependencies, }: { packageManager: string | undefined; @@ -90,6 +92,8 @@ describe("generatePnpmLockfile integration", () => { /** Rewrite the workspace lockfile before isolating, to shape its env document */ mutateLockfile?: (lockfile: string) => string; majorVersion?: number; + targetPackageName?: string; + targetPackageRelativePath?: string; patchedDependencies?: Record; }) { const { tmpBase, workspaceRoot } = await setupFixture(fixture); @@ -103,7 +107,10 @@ describe("generatePnpmLockfile integration", () => { ); } - const targetPackageDir = path.join(workspaceRoot, "apps/svc"); + const targetPackageDir = path.join( + workspaceRoot, + targetPackageRelativePath, + ); const isolateDir = path.join(targetPackageDir, "isolate"); await fs.ensureDir(isolateDir); @@ -114,7 +121,7 @@ describe("generatePnpmLockfile integration", () => { internalDepPackageNames: [], packagesRegistry: {}, targetPackageManifest: { - name: "svc", + name: targetPackageName, version: "1.0.0", dependencies: { "left-pad": "1.3.0" }, ...(packageManager ? { packageManager } : {}), @@ -174,6 +181,46 @@ describe("generatePnpmLockfile integration", () => { }); }); + it.each([ + { + fixture: "pnpm-patched-dependencies-10", + majorVersion: 10, + expected: { + "left-pad@1.3.0": { + path: "patches/left-pad@1.3.0.patch", + hash: "9e0f13b98377d5c4c2e7b7b6ba81f2619588134e4df976f8b79cc200b02cf500", + }, + }, + }, + { + fixture: "pnpm-patched-dependencies-12", + majorVersion: 12, + expected: { + "left-pad@1.3.0": + "9e0f13b98377d5c4c2e7b7b6ba81f2619588134e4df976f8b79cc200b02cf500", + }, + }, + ])( + "writes patch metadata from a real pnpm $majorVersion workspace", + async ({ fixture, majorVersion, expected }) => { + const { documents } = await isolate({ + packageManager: `pnpm@${majorVersion}.0.0`, + fixture, + majorVersion, + targetPackageName: "functions", + targetPackageRelativePath: "packages/functions", + patchedDependencies: { + "left-pad@1.3.0": { + path: "patches/left-pad@1.3.0.patch", + hash: "9e0f13b98377d5c4c2e7b7b6ba81f2619588134e4df976f8b79cc200b02cf500", + }, + }, + }); + + expect(documents.at(-1)?.patchedDependencies).toEqual(expected); + }, + ); + it("carries the env document into the isolated lockfile", async () => { const { content, documents } = await isolate({ packageManager: "pnpm@12.0.0", diff --git a/src/lib/patches/copy-patches.test.ts b/src/lib/patches/copy-patches.test.ts index 4b83c60..faeca7b 100644 --- a/src/lib/patches/copy-patches.test.ts +++ b/src/lib/patches/copy-patches.test.ts @@ -728,12 +728,13 @@ describe("copyPatches", () => { const targetManifest: PackageManifest = { name: "test", version: "1.0.0", - dependencies: { lodash: "^4.0.0" }, + dependencies: { lodash: "^4.0.0", underscore: "^1.0.0" }, }; readTypedYamlSync.mockReturnValue({ patchedDependencies: { "lodash@4.17.21": "patches/lodash.patch", + "underscore@1.13.7": "patches/underscore.patch", }, }); readTypedJson.mockResolvedValue({ @@ -742,6 +743,7 @@ describe("copyPatches", () => { } as PackageManifest); filterPatchedDependencies.mockReturnValue({ "lodash@4.17.21": "patches/lodash.patch", + "underscore@1.13.7": "patches/underscore.patch", }); fs.existsSync.mockReturnValue(true); usePackageManager.mockReturnValue({ @@ -752,6 +754,9 @@ describe("copyPatches", () => { }); readWantedLockfile_v9.mockResolvedValue({ lockfileVersion: "9.0", + patchedDependencies: { + "lodash@4.17.21": "sha256-lodash", + }, } as unknown as Awaited>); await expect( @@ -764,8 +769,9 @@ describe("copyPatches", () => { packagesRegistry: {}, includeDevDependencies: false, }), - ).rejects.toThrow("No hash found for patch lodash@4.17.21 in lockfile"); + ).rejects.toThrow("No hash found for patch underscore@1.13.7 in lockfile"); + expect(fs.ensureDir).not.toHaveBeenCalled(); expect(fs.copy).not.toHaveBeenCalled(); }); From e9bbc492ef556c1324e089d0e6cfacce9498ceaa Mon Sep 17 00:00:00 2001 From: Thijs Koerselman Date: Sat, 29 Aug 2026 12:10:09 +0200 Subject: [PATCH 7/8] Address GLM review findings for patch metadata --- src/isolate.integration.test.ts | 12 +++++++----- src/isolate.ts | 4 ++++ .../generate-pnpm-lockfile.integration.test.ts | 14 ++++++++++++++ .../write-isolate-pnpm-workspace.test.ts | 6 +++--- src/lib/patches/write-isolate-pnpm-workspace.ts | 17 +++++++---------- 5 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/isolate.integration.test.ts b/src/isolate.integration.test.ts index 2d8606c..331c9ad 100644 --- a/src/isolate.integration.test.ts +++ b/src/isolate.integration.test.ts @@ -176,11 +176,13 @@ describe("isolate integration", () => { await fs.readFile(path.join(isolateDir, "pnpm-lock.yaml"), "utf8"), ); - expect(manifest.pnpm?.patchedDependencies).toEqual( - majorVersion >= 11 - ? undefined - : { "left-pad@1.3.0": "patches/left-pad@1.3.0.patch" }, - ); + if (majorVersion >= 11) { + expect(manifest.pnpm).toBeUndefined(); + } else { + expect(manifest.pnpm?.patchedDependencies).toEqual({ + "left-pad@1.3.0": "patches/left-pad@1.3.0.patch", + }); + } expect(workspaceSettings.patchedDependencies).toEqual({ "left-pad@1.3.0": "patches/left-pad@1.3.0.patch", }); diff --git a/src/isolate.ts b/src/isolate.ts index 000b91b..88e7c0f 100644 --- a/src/isolate.ts +++ b/src/isolate.ts @@ -355,6 +355,10 @@ export function createIsolator(initialConfig?: IsolateConfig) { if (shouldRemoveLegacyPnpmPatchedDependencies) { delete manifest.pnpm?.patchedDependencies; + + if (manifest.pnpm && Object.keys(manifest.pnpm).length === 0) { + delete manifest.pnpm; + } } if (hasCopiedPatches) { diff --git a/src/lib/lockfile/helpers/generate-pnpm-lockfile.integration.test.ts b/src/lib/lockfile/helpers/generate-pnpm-lockfile.integration.test.ts index ec98ba8..3969784 100644 --- a/src/lib/lockfile/helpers/generate-pnpm-lockfile.integration.test.ts +++ b/src/lib/lockfile/helpers/generate-pnpm-lockfile.integration.test.ts @@ -218,6 +218,20 @@ describe("generatePnpmLockfile integration", () => { }); expect(documents.at(-1)?.patchedDependencies).toEqual(expected); + + const patchHash = + "9e0f13b98377d5c4c2e7b7b6ba81f2619588134e4df976f8b79cc200b02cf500"; + const patchedVersion = `1.3.0(patch_hash=${patchHash})`; + const projectDocument = documents.at(-1); + + expect(rootImporterOf(projectDocument)).toMatchObject({ + dependencies: { + "left-pad": { specifier: "1.3.0", version: patchedVersion }, + }, + }); + expect(projectDocument?.snapshots).toHaveProperty( + `left-pad@${patchedVersion}`, + ); }, ); diff --git a/src/lib/patches/write-isolate-pnpm-workspace.test.ts b/src/lib/patches/write-isolate-pnpm-workspace.test.ts index b7f0438..645c5c9 100644 --- a/src/lib/patches/write-isolate-pnpm-workspace.test.ts +++ b/src/lib/patches/write-isolate-pnpm-workspace.test.ts @@ -430,7 +430,7 @@ describe("writeIsolatePnpmWorkspace", () => { }, }), ).toThrow( - "Cannot write pnpm 11 patch paths without readable workspace settings", + "Cannot write pnpm patch paths without readable workspace settings", ); expect(fs.copyFileSync).not.toHaveBeenCalled(); @@ -453,7 +453,7 @@ describe("writeIsolatePnpmWorkspace", () => { }, }), ).toThrow( - "Cannot write pnpm 11 patch paths without readable workspace settings", + "Cannot write pnpm patch paths without readable workspace settings", ); expect(fs.copyFileSync).not.toHaveBeenCalled(); @@ -476,7 +476,7 @@ describe("writeIsolatePnpmWorkspace", () => { }, }), ).toThrow( - "Cannot write pnpm 11 patch paths without readable workspace settings", + "Cannot write pnpm patch paths without readable workspace settings", ); expect(fs.copyFileSync).not.toHaveBeenCalled(); diff --git a/src/lib/patches/write-isolate-pnpm-workspace.ts b/src/lib/patches/write-isolate-pnpm-workspace.ts index f758d46..862caac 100644 --- a/src/lib/patches/write-isolate-pnpm-workspace.ts +++ b/src/lib/patches/write-isolate-pnpm-workspace.ts @@ -3,10 +3,7 @@ import path from "node:path"; import { useLogger } from "#/lib/logger"; import type { PatchFile, PnpmSettings } from "#/lib/types"; import { readTypedYamlSync, writeTypedYamlSync } from "#/lib/utils"; -import { - getPnpmPatchedDependenciesOutput, - getPnpmPatchedDependencyPaths, -} from "./pnpm-patched-dependencies"; +import { getPnpmPatchedDependenciesOutput } from "./pnpm-patched-dependencies"; type GeneratedPnpmWorkspaceSettings = PnpmSettings & { packages: string[]; @@ -26,10 +23,10 @@ type GeneratedPnpmWorkspaceSettings = PnpmSettings & { * - The source yaml cannot be read or parsed and pnpm does not need a * `patchedDependencies` field. pnpm 11 and later instead fail clearly when * copied patches require adapted paths in that file. - * - The parsed settings have no `patchedDependencies` field and pnpm is older - * than version 11. + * - The parsed settings have no `patchedDependencies` field and pnpm does not + * need workspace patch paths. * - Every entry in `patchedDependencies` is also present in `copiedPatches` - * and pnpm is older than version 11. + * and pnpm does not need workspace patch paths. * * Otherwise, `patchedDependencies` is rewritten to the entries in * `copiedPatches` (or removed entirely when none remain). pnpm 11 and later @@ -63,7 +60,7 @@ export function writeIsolatePnpmWorkspace({ } catch (error) { if (workspacePatchPaths) { throw new Error( - "Cannot write pnpm 11 patch paths without readable workspace settings", + "Cannot write pnpm patch paths without readable workspace settings", { cause: error }, ); } @@ -78,7 +75,7 @@ export function writeIsolatePnpmWorkspace({ if (!isPnpmWorkspaceSettings(settings)) { if (workspacePatchPaths) { throw new Error( - "Cannot write pnpm 11 patch paths without readable workspace settings", + "Cannot write pnpm patch paths without readable workspace settings", ); } @@ -105,7 +102,7 @@ export function writeIsolatePnpmWorkspace({ return; } - const patchPaths = getPnpmPatchedDependencyPaths(copiedPatches); + const patchPaths = patchOutput.workspace ?? patchOutput.manifest ?? {}; const filteredEntries = Object.entries(patchPaths); if (filteredEntries.length > 0) { From fad046b7c7dd8e8c1b4213fef39accca98a7cbf5 Mon Sep 17 00:00:00 2001 From: Thijs Koerselman Date: Sat, 29 Aug 2026 12:17:59 +0200 Subject: [PATCH 8/8] Address final review findings (patch coverage, diagnostics) --- src/isolate.integration.test.ts | 10 ++++ .../helpers/generate-pnpm-lockfile.ts | 4 +- src/lib/patches/copy-patches.test.ts | 46 +++++++++++++++++++ src/lib/patches/copy-patches.ts | 24 +++++++--- .../patches/pnpm-patched-dependencies.test.ts | 1 + 5 files changed, 76 insertions(+), 9 deletions(-) diff --git a/src/isolate.integration.test.ts b/src/isolate.integration.test.ts index 331c9ad..ff71017 100644 --- a/src/isolate.integration.test.ts +++ b/src/isolate.integration.test.ts @@ -168,6 +168,16 @@ describe("isolate integration", () => { workspaceRoot: "../..", }); + await expect( + fs.readFile( + path.join(isolateDir, "patches", "left-pad@1.3.0.patch"), + "utf8", + ), + ).resolves.toBe(leftPadPatchContents); + await expect( + fs.pathExists(path.join(isolateDir, "patches", "stale-left-pad.patch")), + ).resolves.toBe(false); + const manifest = await fs.readJson(path.join(isolateDir, "package.json")); const workspaceSettings = parse( await fs.readFile(path.join(isolateDir, "pnpm-workspace.yaml"), "utf8"), diff --git a/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts b/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts index cae1ae1..7ac25d1 100644 --- a/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts +++ b/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts @@ -225,8 +225,8 @@ export async function generatePnpmLockfile({ */ if (useVersion9) { /** - * pnpm 11 stores only the patch hash in the lockfile. The matching path - * is written to the isolate's pnpm workspace configuration. + * pnpm 11 and later store only the patch hash in the lockfile. The + * matching path is written to the isolate's pnpm workspace configuration. */ const patchWithoutHash = Object.entries(patchedDependencies ?? {}).find( ([, patchFile]) => diff --git a/src/lib/patches/copy-patches.test.ts b/src/lib/patches/copy-patches.test.ts index faeca7b..0da0a20 100644 --- a/src/lib/patches/copy-patches.test.ts +++ b/src/lib/patches/copy-patches.test.ts @@ -775,6 +775,52 @@ describe("copyPatches", () => { expect(fs.copy).not.toHaveBeenCalled(); }); + it("explains when pnpm 11 patch hashes cannot be read from the lockfile", async () => { + const targetManifest: PackageManifest = { + name: "test", + version: "1.0.0", + dependencies: { lodash: "^4.0.0" }, + }; + + readTypedYamlSync.mockReturnValue({ + patchedDependencies: { + "lodash@4.17.21": "patches/lodash.patch", + }, + }); + readTypedJson.mockResolvedValue({ + name: "root", + version: "1.0.0", + } as PackageManifest); + filterPatchedDependencies.mockReturnValue({ + "lodash@4.17.21": "patches/lodash.patch", + }); + fs.existsSync.mockReturnValue(true); + usePackageManager.mockReturnValue({ + name: "pnpm", + majorVersion: 11, + version: "11.0.0", + packageManagerString: "pnpm@11.0.0", + }); + readWantedLockfile_v9.mockRejectedValue(new Error("invalid lockfile")); + + await expect( + copyPatches({ + workspaceRootDir: "/workspace", + targetPackageDir: "/workspace/packages/test", + internalDepPackageNames: [], + targetPackageManifest: targetManifest, + isolateDir: "/workspace/isolate", + packagesRegistry: {}, + includeDevDependencies: false, + }), + ).rejects.toThrow( + "Could not read pnpm lockfile while resolving patch lodash@4.17.21", + ); + + expect(fs.ensureDir).not.toHaveBeenCalled(); + expect(fs.copy).not.toHaveBeenCalled(); + }); + it("should read the patch hash from the pnpm <=10 object lockfile format (regression: issue #201)", async () => { const targetManifest: PackageManifest = { name: "test", diff --git a/src/lib/patches/copy-patches.ts b/src/lib/patches/copy-patches.ts index 004708d..c15423a 100644 --- a/src/lib/patches/copy-patches.ts +++ b/src/lib/patches/copy-patches.ts @@ -155,7 +155,7 @@ export async function copyPatches({ * Read the pnpm lockfile to get patch hashes. Bun doesn't store hashes in * its lockfile so we skip this for Bun. */ - const lockfilePatchedDependencies = + const lockfilePatchResult = packageManagerName === "pnpm" ? await readLockfilePatchedDependencies(workspaceRootDir) : undefined; @@ -183,7 +183,8 @@ export async function copyPatches({ * `Record` to `Record` (selector to * hash), so the entry may be a bare hash string. See issue #201. */ - const originalPatchFile = lockfilePatchedDependencies?.[packageSpec]; + const originalPatchFile = + lockfilePatchResult?.patchedDependencies?.[packageSpec]; const hash = typeof originalPatchFile === "string" ? originalPatchFile @@ -194,6 +195,13 @@ export async function copyPatches({ usesPnpmWorkspacePatchedDependencies(majorVersion) && !hash ) { + if (lockfilePatchResult?.readError) { + throw new Error( + `Could not read pnpm lockfile while resolving patch ${packageSpec}`, + { cause: lockfilePatchResult.readError }, + ); + } + throw new Error(`No hash found for patch ${packageSpec} in lockfile`); } @@ -237,7 +245,10 @@ export async function copyPatches({ */ async function readLockfilePatchedDependencies( workspaceRootDir: string, -): Promise | undefined> { +): Promise<{ + patchedDependencies?: Record; + readError?: unknown; +}> { try { const { majorVersion } = usePackageManager(); const useVersion9 = majorVersion >= 9; @@ -247,9 +258,8 @@ async function readLockfilePatchedDependencies( ? await readWantedLockfile_v9(lockfileDir, { ignoreIncompatible: false }) : await readWantedLockfile_v8(lockfileDir, { ignoreIncompatible: false }); - return lockfile?.patchedDependencies; - } catch { - /** Package manager not detected or lockfile not readable */ - return undefined; + return { patchedDependencies: lockfile?.patchedDependencies }; + } catch (error) { + return { readError: error }; } } diff --git a/src/lib/patches/pnpm-patched-dependencies.test.ts b/src/lib/patches/pnpm-patched-dependencies.test.ts index 8acffb8..42e350e 100644 --- a/src/lib/patches/pnpm-patched-dependencies.test.ts +++ b/src/lib/patches/pnpm-patched-dependencies.test.ts @@ -14,6 +14,7 @@ const copiedPatches: Record = { describe("usesPnpmWorkspacePatchedDependencies", () => { it("uses workspace patch paths from pnpm 11 onwards", () => { + expect(usesPnpmWorkspacePatchedDependencies(9)).toBe(false); expect(usesPnpmWorkspacePatchedDependencies(10)).toBe(false); expect(usesPnpmWorkspacePatchedDependencies(11)).toBe(true); expect(usesPnpmWorkspacePatchedDependencies(Number.NaN)).toBe(false);