diff --git a/docs/patched-dependencies.md b/docs/patched-dependencies.md index ed13df2..44b85cf 100644 --- a/docs/patched-dependencies.md +++ b/docs/patched-dependencies.md @@ -15,5 +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. The lockfile and `package.json` are updated with the correct -paths. +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 a20b4fa..ff71017 100644 --- a/src/isolate.integration.test.ts +++ b/src/isolate.integration.test.ts @@ -1,11 +1,16 @@ import fs from "fs-extra"; +import { createHash } from "node:crypto"; 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"; 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( @@ -65,4 +70,142 @@ describe("isolate integration", () => { fs.readJson(path.join(result, "package.json")), ).resolves.toMatchObject({ name: "functions-node", version: "1.0.0" }); }); + + 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: ${leftPadPatchHash}`] + : [ + " left-pad@1.3.0:", + " path: patches/left-pad@1.3.0.patch", + ` hash: ${leftPadPatchHash}`, + ]), + "", + ].join("\n"), + ); + await fs.writeFile( + path.join(workspaceRoot, "patches", "left-pad@1.3.0.patch"), + leftPadPatchContents, + ); + 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" }, + pnpm: { + patchedDependencies: { + "left-pad@1.3.0": "patches/stale-left-pad.patch", + }, + }, + }); + await fs.writeFile( + path.join(targetPackageDir, "index.js"), + "export {};\n", + ); + + expect( + createHash("sha256").update(leftPadPatchContents).digest("hex"), + ).toBe(leftPadPatchHash); + + const isolateDir = await isolate({ + targetPackagePath: targetPackageDir, + buildDirName: ".", + 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"), + ); + const lockfile = parse( + await fs.readFile(path.join(isolateDir, "pnpm-lock.yaml"), "utf8"), + ); + + 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", + }); + expect(lockfile.patchedDependencies).toEqual( + majorVersion >= 11 + ? { "left-pad@1.3.0": leftPadPatchHash } + : { + "left-pad@1.3.0": { + path: "patches/left-pad@1.3.0.patch", + hash: leftPadPatchHash, + }, + }, + ); + }, + ); }); diff --git a/src/isolate.ts b/src/isolate.ts index f4d4fd0..88e7c0f 100644 --- a/src/isolate.ts +++ b/src/isolate.ts @@ -28,7 +28,14 @@ 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 { + getPnpmPatchedDependenciesOutput, + usesPnpmWorkspacePatchedDependencies, +} from "./lib/patches/pnpm-patched-dependencies"; +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 +44,6 @@ import { isRushWorkspace, readTypedJson, resetIsolateDir, - writeTypedYamlSync, } from "./lib/utils"; const __dirname = getDirname(import.meta.url); @@ -330,34 +336,53 @@ export function createIsolator(initialConfig?: IsolateConfig) { }); const hasCopiedPatches = Object.keys(copiedPatches).length > 0; - - /** Update manifest if patches were copied or npm fallback is needed */ - if (hasCopiedPatches || usedFallbackToNpm) { + const pnpmPatchOutput = getPnpmPatchedDependenciesOutput({ + majorVersion: packageManager.majorVersion, + copiedPatches, + }); + 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 (hasCopiedPatches) { - /** - * Extract just the paths for the manifest (lockfile needs full - * PatchFile). PNPM stores patches under pnpm.patchedDependencies, Bun - * at the top level. - */ - const patchEntries = Object.fromEntries( - Object.entries(copiedPatches).map(([spec, patchFile]) => [ - spec, - patchFile.path, - ]), - ); + if (shouldRemoveLegacyPnpmPatchedDependencies) { + delete manifest.pnpm?.patchedDependencies; - if (packageManager.name === "bun") { - manifest.patchedDependencies = patchEntries; - } else { - manifest.pnpm ??= {}; - manifest.pnpm.patchedDependencies = patchEntries; + if (manifest.pnpm && Object.keys(manifest.pnpm).length === 0) { + delete manifest.pnpm; } + } - log.debug( - `Added ${Object.keys(copiedPatches).length} patches to isolated package.json`, - ); + if (hasCopiedPatches) { + if (packageManager.name === "bun") { + manifest.patchedDependencies = Object.fromEntries( + Object.entries(copiedPatches).map(([spec, patchFile]) => [ + spec, + patchFile.path, + ]), + ); + 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`, + ); + } + } } if (usedFallbackToNpm) { @@ -393,16 +418,26 @@ 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({ workspaceRootDir, isolateDir, + majorVersion: packageManager.majorVersion, copiedPatches, }); } + + if (pnpmPatchOutput.workspace) { + log.debug( + `Added ${Object.keys(copiedPatches).length} patches to isolated pnpm-workspace.yaml`, + ); + } } if (packageManager.name === "bun" && !config.forceNpm) { 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 9430a2a..3969784 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,19 @@ describe("generatePnpmLockfile integration", () => { packageManager, fixture = "pnpm-two-document", mutateLockfile, + majorVersion = 12, + targetPackageName = "svc", + targetPackageRelativePath = "apps/svc", + patchedDependencies, }: { packageManager: string | undefined; fixture?: string; /** 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); cleanupPaths.push(tmpBase); @@ -98,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); @@ -109,13 +121,14 @@ describe("generatePnpmLockfile integration", () => { internalDepPackageNames: [], packagesRegistry: {}, targetPackageManifest: { - name: "svc", + name: targetPackageName, version: "1.0.0", dependencies: { "left-pad": "1.3.0" }, ...(packageManager ? { packageManager } : {}), }, - majorVersion: 12, + majorVersion, includeDevDependencies: false, + patchedDependencies, }); return readLockfileDocuments(isolateDir); @@ -132,6 +145,96 @@ 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.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); + + 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}`, + ); + }, + ); + 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..4bb4555 100644 --- a/src/lib/lockfile/helpers/generate-pnpm-lockfile.test.ts +++ b/src/lib/lockfile/helpers/generate-pnpm-lockfile.test.ts @@ -451,6 +451,76 @@ 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("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 869b71b..7ac25d1 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"; /** @@ -221,17 +225,28 @@ 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 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]) => + usesPnpmWorkspacePatchedDependencies(majorVersion) && !patchFile.hash, + ); + + assert( + !patchWithoutHash, + `Patch ${patchWithoutHash?.[0]} has no lockfile hash`, + ); + + const { lockfile: lockfilePatchedDependencies } = + getPnpmPatchedDependenciesOutput({ + majorVersion, + copiedPatches: patchedDependencies, + }); + await writeWantedLockfile_v9(isolateDir, { ...prunedLockfile, - patchedDependencies, + patchedDependencies: lockfilePatchedDependencies, } as unknown as Parameters[1]); /** diff --git a/src/lib/patches/copy-patches.test.ts b/src/lib/patches/copy-patches.test.ts index b321d07..0da0a20 100644 --- a/src/lib/patches/copy-patches.test.ts +++ b/src/lib/patches/copy-patches.test.ts @@ -724,6 +724,103 @@ 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", underscore: "^1.0.0" }, + }; + + readTypedYamlSync.mockReturnValue({ + patchedDependencies: { + "lodash@4.17.21": "patches/lodash.patch", + "underscore@1.13.7": "patches/underscore.patch", + }, + }); + readTypedJson.mockResolvedValue({ + name: "root", + version: "1.0.0", + } as PackageManifest); + filterPatchedDependencies.mockReturnValue({ + "lodash@4.17.21": "patches/lodash.patch", + "underscore@1.13.7": "patches/underscore.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", + patchedDependencies: { + "lodash@4.17.21": "sha256-lodash", + }, + } 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 underscore@1.13.7 in lockfile"); + + expect(fs.ensureDir).not.toHaveBeenCalled(); + 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 e73b5a6..c15423a 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, @@ -154,11 +155,16 @@ 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; + const patchFilesToCopy: { + packageSpec: string; + sourcePath: string; + targetPath: string; + }[] = []; const copiedPatches: Record = {}; for (const [packageSpec, patchPath] of Object.entries(filteredPatches)) { @@ -171,24 +177,34 @@ 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 * `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 : (originalPatchFile?.hash ?? ""); + if ( + packageManagerName === "pnpm" && + 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`); + } + if (packageManagerName === "pnpm" && !hash) { log.warn(`No hash found for patch ${packageSpec} in lockfile`); } @@ -197,6 +213,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) { @@ -218,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; @@ -228,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 new file mode 100644 index 0000000..42e350e --- /dev/null +++ b/src/lib/patches/pnpm-patched-dependencies.test.ts @@ -0,0 +1,56 @@ +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(9)).toBe(false); + 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 d2f7daf..645c5c9 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: { @@ -50,6 +53,7 @@ describe("writeIsolatePnpmWorkspace", () => { writeIsolatePnpmWorkspace({ workspaceRootDir, isolateDir, + majorVersion: 10, copiedPatches, }); @@ -77,6 +81,7 @@ describe("writeIsolatePnpmWorkspace", () => { writeIsolatePnpmWorkspace({ workspaceRootDir, isolateDir, + majorVersion: 10, copiedPatches: {}, }); @@ -95,6 +100,7 @@ describe("writeIsolatePnpmWorkspace", () => { writeIsolatePnpmWorkspace({ workspaceRootDir, isolateDir, + majorVersion: 10, copiedPatches: {}, }); @@ -105,6 +111,78 @@ 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("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("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/*"], @@ -123,6 +201,7 @@ describe("writeIsolatePnpmWorkspace", () => { writeIsolatePnpmWorkspace({ workspaceRootDir, isolateDir, + majorVersion: 10, copiedPatches, }); @@ -160,6 +239,7 @@ describe("writeIsolatePnpmWorkspace", () => { writeIsolatePnpmWorkspace({ workspaceRootDir, isolateDir, + majorVersion: 11, copiedPatches: {}, }); @@ -174,6 +254,68 @@ 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", + }, + }); + + writeIsolatePnpmWorkspace({ + workspaceRootDir, + isolateDir, + majorVersion: 11, + copiedPatches: {}, + }); + + expect(fs.copyFileSync).not.toHaveBeenCalled(); + expect(writeTypedYamlSync).toHaveBeenCalledWith( + "/workspace/isolate/pnpm-workspace.yaml", + { + 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", + }, + }, + ); + }); + /** * When patches are being filtered, the rewrite path must still carry * `allowBuilds` into the output yaml — otherwise pnpm 11's build-script @@ -201,6 +343,7 @@ describe("writeIsolatePnpmWorkspace", () => { writeIsolatePnpmWorkspace({ workspaceRootDir, isolateDir, + majorVersion: 11, copiedPatches, }); @@ -239,6 +382,7 @@ describe("writeIsolatePnpmWorkspace", () => { writeIsolatePnpmWorkspace({ workspaceRootDir, isolateDir, + majorVersion: 10, copiedPatches, }); @@ -257,6 +401,7 @@ describe("writeIsolatePnpmWorkspace", () => { writeIsolatePnpmWorkspace({ workspaceRootDir, isolateDir, + majorVersion: 10, copiedPatches: {}, }); @@ -266,4 +411,75 @@ describe("writeIsolatePnpmWorkspace", () => { "/workspace/isolate/pnpm-workspace.yaml", ); }); + + it("rejects pnpm 11 patch output when the yaml cannot be parsed", () => { + readTypedYamlSync.mockImplementation(() => { + throw new Error("bad yaml"); + }); + + 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 patch paths without readable workspace settings", + ); + + expect(fs.copyFileSync).not.toHaveBeenCalled(); + expect(writeTypedYamlSync).not.toHaveBeenCalled(); + }); + + it("rejects pnpm 11 patch output when the yaml has no settings", () => { + readTypedYamlSync.mockReturnValue(undefined); + + 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 patch paths without readable workspace settings", + ); + + expect(fs.copyFileSync).not.toHaveBeenCalled(); + 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 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 e5aeee3..862caac 100644 --- a/src/lib/patches/write-isolate-pnpm-workspace.ts +++ b/src/lib/patches/write-isolate-pnpm-workspace.ts @@ -3,6 +3,11 @@ 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 } from "./pnpm-patched-dependencies"; + +type GeneratedPnpmWorkspaceSettings = PnpmSettings & { + packages: string[]; +}; /** * Copy `pnpm-workspace.yaml` from the workspace root to the isolate directory, @@ -11,36 +16,55 @@ 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. + * - 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 does not + * need workspace patch paths. * - Every entry in `patchedDependencies` is also present in `copiedPatches` - * (no exclusions, so rewriting would only churn formatting). + * and pnpm does not need workspace patch paths. * * 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(); 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, + }); + const workspacePatchPaths = patchOutput.workspace; try { - settings = readTypedYamlSync(sourcePath) as PnpmSettings | undefined; + settings = readTypedYamlSync(sourcePath); } catch (error) { + if (workspacePatchPaths) { + throw new Error( + "Cannot write pnpm patch paths without readable workspace settings", + { cause: error }, + ); + } + log.warn( `Could not read pnpm-workspace.yaml, falling back to verbatim copy: ${error instanceof Error ? error.message : String(error)}`, ); @@ -48,27 +72,38 @@ export function writeIsolatePnpmWorkspace({ return; } - if (!settings || !settings.patchedDependencies) { + if (!isPnpmWorkspaceSettings(settings)) { + if (workspacePatchPaths) { + throw new Error( + "Cannot write pnpm patch paths without readable workspace settings", + ); + } + + fs.copyFileSync(sourcePath, targetPath); + return; + } + + 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 sourceSpecs = Object.keys(settings.patchedDependencies ?? {}); const copiedSpecs = new Set(Object.keys(copiedPatches)); const hasExclusions = sourceSpecs.some((spec) => !copiedSpecs.has(spec)); - if (!hasExclusions) { + if (!hasExclusions && !workspacePatchPaths) { fs.copyFileSync(sourcePath, targetPath); return; } - const filteredEntries = Object.entries(copiedPatches).map( - ([spec, patchFile]) => [spec, patchFile.path] as const, - ); + const patchPaths = patchOutput.workspace ?? patchOutput.manifest ?? {}; + const filteredEntries = Object.entries(patchPaths); if (filteredEntries.length > 0) { settings.patchedDependencies = Object.fromEntries(filteredEntries); @@ -78,3 +113,37 @@ 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, + packages, + majorVersion, + copiedPatches, +}: { + isolateDir: string; + packages: string[]; + majorVersion: number; + copiedPatches: Record; +}) { + const settings: GeneratedPnpmWorkspaceSettings = { packages }; + const patchOutput = getPnpmPatchedDependenciesOutput({ + majorVersion, + copiedPatches, + }); + + if (patchOutput.workspace) { + settings.patchedDependencies = patchOutput.workspace; + } + + writeTypedYamlSync(path.join(isolateDir, "pnpm-workspace.yaml"), settings); +}