diff --git a/.changeset/skip-absent-dependency-links.md b/.changeset/skip-absent-dependency-links.md new file mode 100644 index 0000000000..b88bf843c0 --- /dev/null +++ b/.changeset/skip-absent-dependency-links.md @@ -0,0 +1,6 @@ +--- +"@pnpm/deps.graph-builder": patch +"pnpm": patch +--- + +Fixed frozen installs creating symlinks to the working directory for skipped optional dependencies and unresolved peer dependencies [#16454](https://github.com/pnpm/pnpm/issues/16454). diff --git a/pnpm11/deps/graph-builder/src/lockfileToDepGraph.ts b/pnpm11/deps/graph-builder/src/lockfileToDepGraph.ts index c930ec0044..70fd3bb146 100644 --- a/pnpm11/deps/graph-builder/src/lockfileToDepGraph.ts +++ b/pnpm11/deps/graph-builder/src/lockfileToDepGraph.ts @@ -204,7 +204,8 @@ function getChildrenPaths ( ): { [alias: string]: string } { const children: { [alias: string]: string } = {} for (const [alias, ref] of Object.entries(allDeps)) { - children[alias] = resolveChildPath(ctx, alias, ref, peerDeps, parent) + const childPath = resolveChildPath(ctx, alias, ref, peerDeps, parent) + if (childPath != null) children[alias] = childPath } return children } @@ -215,7 +216,7 @@ function resolveChildPath ( ref: string, peerDeps: Set | null, parent: { importerId: string, pkgDir?: string } -): string { +): string | undefined { const packageRootLinkTarget = dp.packageRootLinkTarget(ref) if (packageRootLinkTarget != null && parent.pkgDir != null) { return path.join(parent.pkgDir, packageRootLinkTarget) @@ -233,7 +234,7 @@ function resolveDepPathLocation ( ref: string, alias: string, peerDeps: Set | null -): string { +): string | undefined { if (ctx.locationByDepPath[childRelDepPath]) { return ctx.locationByDepPath[childRelDepPath] } @@ -246,5 +247,5 @@ function resolveDepPathLocation ( if (!ctx.skipped.has(childRelDepPath as DepPath) && (peerDeps == null || !peerDeps.has(alias))) { throw new Error(`${childRelDepPath} not found in ${WANTED_LOCKFILE}`) } - return '' + return undefined } diff --git a/pnpm11/deps/graph-builder/test/lockfileToDepGraph.test.ts b/pnpm11/deps/graph-builder/test/lockfileToDepGraph.test.ts index d42d729e11..f7069c19d8 100644 --- a/pnpm11/deps/graph-builder/test/lockfileToDepGraph.test.ts +++ b/pnpm11/deps/graph-builder/test/lockfileToDepGraph.test.ts @@ -6,6 +6,7 @@ import path from 'node:path' import { expect, test } from '@jest/globals' import { depPathToFilename } from '@pnpm/deps.path' import type { LockfileObject } from '@pnpm/lockfile.fs' +import type { DepPath } from '@pnpm/types' import { lockfileToDepGraph, type LockfileToDepGraphOptions } from '../src/lockfileToDepGraph.js' @@ -124,3 +125,47 @@ test('lockfileToDepGraph rejects a global-virtual-store slot whose version escap lockfileToDepGraph(lockfile, null, opts) ).rejects.toThrow(expect.objectContaining({ code: 'ERR_PNPM_INVALID_DEPENDENCY_NAME' })) }) + +test.each(['skipped optional dependency', 'unresolved peer dependency'])( + 'lockfileToDepGraph omits children with %s', async (scenario) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-builder-')) + const isOptional = scenario === 'skipped optional dependency' + const lockfile = { + lockfileVersion: '9.0', + importers: { + '.': { + specifiers: { parent: '1.0.0', ...(isOptional ? { missing: '1.0.0' } : {}) }, + dependencies: { parent: '1.0.0' }, + ...(isOptional ? { optionalDependencies: { missing: '1.0.0' } } : {}), + }, + }, + packages: { + 'parent@1.0.0': { + resolution: { tarball: 'https://example.com/parent.tgz' }, + dependencies: { child: '1.0.0', ...(!isOptional ? { missing: '1.0.0' } : {}) }, + ...(isOptional + ? { optionalDependencies: { missing: '1.0.0' } } + : { peerDependencies: { missing: '*' } }), + }, + 'child@1.0.0': { resolution: { tarball: 'https://example.com/child.tgz' } }, + }, + } as unknown as LockfileObject + const opts = { + ...graphOpts(dir), + includeUnchangedDeps: true, + skipped: new Set(isOptional ? ['missing@1.0.0' as DepPath] : []), + } + const parentDir = path.join(opts.virtualStoreDir, 'parent@1.0.0', 'node_modules', 'parent') + const childDir = path.join(opts.virtualStoreDir, 'child@1.0.0', 'node_modules', 'child') + fs.mkdirSync(parentDir, { recursive: true }) + fs.mkdirSync(childDir, { recursive: true }) + try { + const { graph, directDependenciesByImporterId } = await lockfileToDepGraph(lockfile, lockfile, opts) + + expect(graph[parentDir].children).toStrictEqual({ child: childDir }) + expect(directDependenciesByImporterId['.']).toStrictEqual({ parent: parentDir }) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } + } +)