diff --git a/package.json b/package.json index 131c5a4..1fe2ff5 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "dependencies": { "@npmcli/arborist": "^9.4.2", "@npmcli/config": "^10.8.1", + "@pnpm/deps.path": "^1100.1.0", "@pnpm/logger": "^1100.0.0", "@pnpm/types": "^1001.3.0", "consola": "^3.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7675334..15c6b54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@npmcli/config': specifier: ^10.8.1 version: 10.9.0 + '@pnpm/deps.path': + specifier: ^1100.1.0 + version: 1100.1.0 '@pnpm/logger': specifier: ^1100.0.0 version: 1100.0.0 @@ -3301,7 +3304,7 @@ snapshots: lru-cache: 11.3.6 npm-pick-manifest: 11.0.3 proc-log: 6.1.0 - semver: 7.7.4 + semver: 7.8.5 which: 6.0.1 '@npmcli/installed-package-contents@4.0.0': @@ -3523,13 +3526,13 @@ snapshots: '@pnpm/crypto.base32-hash': 2.0.0 '@pnpm/types': 9.4.2 encode-registry: 3.0.1 - semver: 7.7.4 + semver: 7.8.5 '@pnpm/dependency-path@3.0.0': dependencies: '@pnpm/crypto.base32-hash': 3.0.0 '@pnpm/types': 10.0.0 - semver: 7.7.4 + semver: 7.8.5 '@pnpm/deps.path@1100.1.0': dependencies: @@ -3654,7 +3657,7 @@ snapshots: '@pnpm/lockfile-types': 5.1.5 comver-to-semver: 1.0.0 ramda: '@pnpm/ramda@0.28.1' - semver: 7.7.4 + semver: 7.8.5 '@pnpm/network.git-utils@1100.0.3': dependencies: @@ -4832,7 +4835,7 @@ snapshots: graceful-fs: 4.2.11 nopt: 9.0.0 proc-log: 6.1.0 - semver: 7.7.4 + semver: 7.8.5 tar: 7.5.15 tinyglobby: 0.2.16 undici: 6.25.0 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 3969784..eddba34 100644 --- a/src/lib/lockfile/helpers/generate-pnpm-lockfile.integration.test.ts +++ b/src/lib/lockfile/helpers/generate-pnpm-lockfile.integration.test.ts @@ -278,6 +278,10 @@ describe("generatePnpmLockfile integration", () => { "my-config": { specifier: "1.0.0", version: "1.0.0" }, }); expect(rootImporter).not.toHaveProperty("packageManagerDependencies"); + expect(documents[0]?.packages).toEqual({ + "my-config@1.0.0": expect.any(Object), + }); + expect(documents[0]?.snapshots).toEqual({}); }); /** diff --git a/src/lib/lockfile/helpers/generate-pnpm-lockfile.test.ts b/src/lib/lockfile/helpers/generate-pnpm-lockfile.test.ts index 3f456b2..f0bec62 100644 --- a/src/lib/lockfile/helpers/generate-pnpm-lockfile.test.ts +++ b/src/lib/lockfile/helpers/generate-pnpm-lockfile.test.ts @@ -605,6 +605,49 @@ describe("generatePnpmLockfile", () => { snapshots: {}, } satisfies EnvLockfile; + const envLockfileWithConfigDependency = { + lockfileVersion: "9.0", + importers: { + ".": { + configDependencies: { + "my-config": { specifier: "1.0.0", version: "1.0.0" }, + }, + packageManagerDependencies: { + pnpm: { specifier: "12.0.0", version: "12.0.0" }, + }, + }, + }, + packages: { + "my-config@1.0.0": { resolution: { integrity: "config" } }, + "config-runtime@1.0.0": { resolution: { integrity: "runtime" } }, + "patched-runtime@1.0.0(patch_hash=abc)": { + resolution: { integrity: "patched" }, + }, + "pnpm@12.0.0": { resolution: { integrity: "pnpm" } }, + "@pnpm/exe.linux-x64@12.0.0": { + resolution: { integrity: "executable" }, + }, + "unrelated@1.0.0": { resolution: { integrity: "unrelated" } }, + }, + snapshots: { + "my-config@1.0.0": { + dependencies: { + "config-runtime": "1.0.0(peer@2.0.0)", + "patched-runtime": "1.0.0(patch_hash=abc)", + }, + }, + "config-runtime@1.0.0(peer@2.0.0)": {}, + "patched-runtime@1.0.0(patch_hash=abc)": {}, + "pnpm@12.0.0": { + optionalDependencies: { + "@pnpm/exe.linux-x64": "12.0.0", + }, + }, + "@pnpm/exe.linux-x64@12.0.0": {}, + "unrelated@1.0.0": {}, + }, + } satisfies EnvLockfile; + beforeEach(() => { const lockfile = createMockLockfile(); readWantedLockfile_v9.mockResolvedValue(lockfile as never); @@ -668,6 +711,94 @@ describe("generatePnpmLockfile", () => { expect(writeEnvLockfile_v9).not.toHaveBeenCalled(); }); + it("should prune package manager packages when the output omits packageManager", async () => { + readEnvLockfile_v9.mockResolvedValue(envLockfileWithConfigDependency); + + await generate({ name: "my-app", version: "1.0.0" }); + + expect(writeEnvLockfile_v9).toHaveBeenCalledWith( + "/workspace/apps/my-app/isolate", + { + lockfileVersion: "9.0", + importers: { + ".": { + configDependencies: { + "my-config": { specifier: "1.0.0", version: "1.0.0" }, + }, + }, + }, + packages: { + "my-config@1.0.0": { resolution: { integrity: "config" } }, + "config-runtime@1.0.0": { + resolution: { integrity: "runtime" }, + }, + "patched-runtime@1.0.0(patch_hash=abc)": { + resolution: { integrity: "patched" }, + }, + }, + snapshots: { + "my-config@1.0.0": { + dependencies: { + "config-runtime": "1.0.0(peer@2.0.0)", + "patched-runtime": "1.0.0(patch_hash=abc)", + }, + }, + "config-runtime@1.0.0(peer@2.0.0)": {}, + "patched-runtime@1.0.0(patch_hash=abc)": {}, + }, + }, + ); + }); + + it("should omit config dependencies that a generated Rush workspace does not declare", async () => { + isRushWorkspace.mockReturnValue(true); + readEnvLockfile_v9.mockResolvedValue(envLockfileWithConfigDependency); + + await generate({ + name: "my-app", + version: "1.0.0", + packageManager: "pnpm@12.0.0", + }); + + expect(writeEnvLockfile_v9).toHaveBeenCalledWith( + "/workspace/apps/my-app/isolate", + { + lockfileVersion: "9.0", + importers: { + ".": { + configDependencies: {}, + packageManagerDependencies: { + pnpm: { specifier: "12.0.0", version: "12.0.0" }, + }, + }, + }, + packages: { + "pnpm@12.0.0": { resolution: { integrity: "pnpm" } }, + "@pnpm/exe.linux-x64@12.0.0": { + resolution: { integrity: "executable" }, + }, + }, + snapshots: { + "pnpm@12.0.0": { + optionalDependencies: { + "@pnpm/exe.linux-x64": "12.0.0", + }, + }, + "@pnpm/exe.linux-x64@12.0.0": {}, + }, + }, + ); + }); + + it("should skip the env document for a Rush output without packageManager", async () => { + isRushWorkspace.mockReturnValue(true); + readEnvLockfile_v9.mockResolvedValue(envLockfileWithConfigDependency); + + await generate({ name: "my-app", version: "1.0.0" }); + + expect(writeEnvLockfile_v9).not.toHaveBeenCalled(); + }); + it("should skip the env document when the workspace lockfile has none", async () => { readEnvLockfile_v9.mockResolvedValue(null); diff --git a/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts b/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts index 80eb537..5061172 100644 --- a/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts +++ b/src/lib/lockfile/helpers/generate-pnpm-lockfile.ts @@ -5,6 +5,7 @@ import { getLockfileImporterId as getLockfileImporterId_v8, writeWantedLockfile as writeWantedLockfile_v8, } from "pnpm_lockfile_file_v8"; +import { refToRelative, removePeersSuffix } from "@pnpm/deps.path"; import type { EnvLockfile } from "pnpm_lockfile_file_v9"; import { getLockfileImporterId as getLockfileImporterId_v9, @@ -14,7 +15,7 @@ import { } from "pnpm_lockfile_file_v9"; import { pruneLockfile as pruneLockfile_v8 } from "pnpm_prune_lockfile_v8"; import { pruneLockfile as pruneLockfile_v9 } from "pnpm_prune_lockfile_v9"; -import { omit, pick } from "remeda"; +import { pick } from "remeda"; import { useLogger } from "#/lib/logger"; import type { PackageManifest, PackagesRegistry, PatchFile } from "#/lib/types"; import { @@ -246,6 +247,7 @@ export async function generatePnpmLockfile({ lockfileRootDir, isolateDir, targetPackageManifest, + isRush, }); } else { await writeWantedLockfile_v8(isolateDir, { @@ -262,32 +264,25 @@ export async function generatePnpmLockfile({ } /** - * Since pnpm 12 the lockfile can be a stream of two YAML documents: an "env" - * document, followed by the project document. The env document pins two - * independent things — the package manager itself - * (`packageManagerDependencies`) and the workspace's config dependencies - * (`configDependencies`) — recording the resolutions and integrity hashes for - * specifiers that live in `pnpm-workspace.yaml`. An isolated output that keeps - * either of those but drops the env document is rejected by - * `pnpm install --frozen-lockfile` with - * ERR_PNPM_FROZEN_LOCKFILE_WITH_OUTDATED_LOCKFILE. - * - * The env document describes the environment rather than the workspace graph, - * so there is nothing to prune from it and it is copied without pruning. The - * one thing that can need dropping is `packageManagerDependencies`: when - * `omitPackageManager` strips the field from the output manifest, pnpm would - * consider that pin stale for the opposite reason. The config dependencies - * still have to survive that, because `pnpm-workspace.yaml` is copied to the - * isolate verbatim and keeps declaring them. + * Since pnpm 12 the lockfile can be a stream of two YAML documents. The leading + * env document pins the package manager and workspace config dependencies, + * including their resolution graphs. An isolated output keeps only the pins + * it still declares: `omitPackageManager` removes the package manager pin, and + * Rush output removes config pins because its workspace file is generated + * without config dependency declarations. The corresponding package and + * snapshot graphs are pruned by reachability when either kind of pin is + * removed. When every pin remains, the original document is copied verbatim. */ async function copyEnvLockfile({ lockfileRootDir, isolateDir, targetPackageManifest, + isRush, }: { lockfileRootDir: string; isolateDir: string; targetPackageManifest: PackageManifest; + isRush: boolean; }) { const log = useLogger(); @@ -327,58 +322,130 @@ async function copyEnvLockfile({ return; } - if (targetPackageManifest.packageManager) { + if (targetPackageManifest.packageManager && !isRush) { log.debug("Copying the lockfile env document"); await writeEnvLockfile_v9(isolateDir, envLockfile); return; } - const withoutPackageManager = omitEnvPackageManagerDependencies(envLockfile); + const filteredEnvLockfile = filterEnvLockfile(envLockfile, { + keepConfigDependencies: !isRush, + keepPackageManagerDependencies: Boolean( + targetPackageManifest.packageManager, + ), + }); - if (!pinsConfigDependencies(withoutPackageManager)) { + if (!pinsEnvDependencies(filteredEnvLockfile)) { log.debug( - "Skipping the lockfile env document because the output manifest has no packageManager field and the document pins no config dependencies", + "Skipping the lockfile env document because the isolated output declares none of its dependencies", ); return; } - log.debug( - "Copying the lockfile env document without its packageManagerDependencies", - ); + log.debug("Copying the filtered lockfile env document"); - await writeEnvLockfile_v9(isolateDir, withoutPackageManager); + await writeEnvLockfile_v9(isolateDir, filteredEnvLockfile); } /** - * Strip the `packageManagerDependencies` pin from every importer of an env - * document, leaving the rest of it untouched. The `packages` and `snapshots` - * entries the pin resolved to are left in place, because nothing reads them: - * verified by running `pnpm install --frozen-lockfile` (pnpm 12) against - * exactly this output — the lockfile is accepted as up to date, and env - * entries never enter the isolate's virtual store whether an importer - * references them or not. Pruning them would be a reachability walk from the - * remaining `configDependencies` rather than a re-resolution, so it is - * affordable; it is left undone because the residue is some unread YAML. + * Keep only the env dependencies declared by the isolated output, then retain + * their reachable package and snapshot entries. Rush isolates use a generated + * workspace file and therefore declare no config dependencies; ordinary + * isolates keep the copied workspace declarations. The output manifest + * independently determines whether the package manager pin remains. */ -function omitEnvPackageManagerDependencies( +function filterEnvLockfile( envLockfile: EnvLockfile, + { + keepConfigDependencies, + keepPackageManagerDependencies, + }: { + keepConfigDependencies: boolean; + keepPackageManagerDependencies: boolean; + }, ): EnvLockfile { + const importers = Object.fromEntries( + Object.entries(envLockfile.importers).map(([importerId, importer]) => { + const { + configDependencies, + packageManagerDependencies, + ...otherImporterFields + } = importer; + + return [ + importerId, + { + ...otherImporterFields, + configDependencies: keepConfigDependencies + ? (configDependencies ?? {}) + : {}, + ...(keepPackageManagerDependencies && packageManagerDependencies + ? { packageManagerDependencies } + : {}), + }, + ]; + }), + ) as EnvLockfile["importers"]; + + const reachableSnapshotKeys = collectReachableEnvSnapshotKeys({ + importers, + snapshots: envLockfile.snapshots, + }); + const reachablePackageKeys = new Set( + [...reachableSnapshotKeys].map((packageKey) => + removePeersSuffix(packageKey), + ), + ); + return { ...envLockfile, - importers: Object.fromEntries( - Object.entries(envLockfile.importers).map(([importerId, importer]) => [ - importerId, - omit(importer, ["packageManagerDependencies"]), - ]), - ) as EnvLockfile["importers"], + importers, + packages: pick(envLockfile.packages, [...reachablePackageKeys]), + snapshots: pick(envLockfile.snapshots, [...reachableSnapshotKeys]), }; } -/** Whether an env document pins any config dependency */ -function pinsConfigDependencies(envLockfile: EnvLockfile) { +/** Find every env package reachable from the retained importer dependencies. */ +function collectReachableEnvSnapshotKeys({ + importers, + snapshots, +}: Pick) { + const pending = Object.values(importers).flatMap((importer) => + Object.entries({ + ...importer.configDependencies, + ...importer.packageManagerDependencies, + }).map(([packageName, dependency]) => + refToRelative(dependency.version, packageName), + ), + ); + const reachable = new Set(); + + for (const packageKey of pending) { + if (!packageKey || reachable.has(packageKey)) continue; + + reachable.add(packageKey); + + const snapshot = snapshots[packageKey]; + if (!snapshot) continue; + + for (const [dependencyName, reference] of Object.entries({ + ...snapshot.dependencies, + ...snapshot.optionalDependencies, + })) { + pending.push(refToRelative(reference, dependencyName)); + } + } + + return reachable; +} + +/** Whether an env document still pins any dependency. */ +function pinsEnvDependencies(envLockfile: EnvLockfile) { return Object.values(envLockfile.importers).some( - (importer) => Object.keys(importer.configDependencies ?? {}).length > 0, + (importer) => + Object.keys(importer.configDependencies ?? {}).length > 0 || + Object.keys(importer.packageManagerDependencies ?? {}).length > 0, ); }