diff --git a/.changeset/guard-missing-resolution-graph-builder.md b/.changeset/guard-missing-resolution-graph-builder.md new file mode 100644 index 0000000000..952db509e7 --- /dev/null +++ b/.changeset/guard-missing-resolution-graph-builder.md @@ -0,0 +1,6 @@ +--- +"@pnpm/deps.graph-builder": patch +"pnpm": patch +--- + +Fix `TypeError: Cannot use 'in' operator to search for 'directory' in undefined` during `pnpm install --frozen-lockfile` when a peer-dep variant snapshot omits its `resolution` field (the variant inherits resolution from the base entry, so this shape is valid in the lockfile but the graph builder didn't guard the access). diff --git a/deps/graph-builder/src/lockfileToDepGraph.ts b/deps/graph-builder/src/lockfileToDepGraph.ts index 0d2ddf4e2a..2a1dec5b64 100644 --- a/deps/graph-builder/src/lockfileToDepGraph.ts +++ b/deps/graph-builder/src/lockfileToDepGraph.ts @@ -214,7 +214,11 @@ async function buildGraphFromPackages ( return } - const isDirectoryDep = 'directory' in pkgSnapshot.resolution && pkgSnapshot.resolution.directory != null + // Peer-dep variant snapshots (e.g. `pkg@1.0.0(peer@2.0.0)`) inherit + // their resolution from the base entry and may omit `resolution` + // themselves. Guard so the `'directory' in …` check doesn't crash + // with `Cannot use 'in' operator to search for 'directory' in undefined`. + const isDirectoryDep = pkgSnapshot.resolution != null && 'directory' in pkgSnapshot.resolution && pkgSnapshot.resolution.directory != null if (isDirectoryDep && opts.ignoreLocalPackages) { logger.info({ message: `Skipping local dependency ${pkgName}@${pkgVersion} (file: protocol)`, diff --git a/installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/package.json b/installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/package.json new file mode 100644 index 0000000000..83e000174f --- /dev/null +++ b/installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/package.json @@ -0,0 +1,8 @@ +{ + "name": "peer-variant-missing-resolution", + "version": "1.0.0", + "private": true, + "dependencies": { + "pkg-a": "workspace:*" + } +} diff --git a/installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/packages/peer/package.json b/installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/packages/peer/package.json new file mode 100644 index 0000000000..2b8b6606e6 --- /dev/null +++ b/installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/packages/peer/package.json @@ -0,0 +1,5 @@ +{ + "name": "peer", + "version": "1.0.0", + "private": true +} diff --git a/installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/packages/pkg-a/package.json b/installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/packages/pkg-a/package.json new file mode 100644 index 0000000000..d0a16e5de2 --- /dev/null +++ b/installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/packages/pkg-a/package.json @@ -0,0 +1,8 @@ +{ + "name": "pkg-a", + "version": "1.0.0", + "private": true, + "peerDependencies": { + "peer": "1.0.0" + } +} diff --git a/installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/pnpm-lock.yaml b/installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/pnpm-lock.yaml new file mode 100644 index 0000000000..367a2ecfb7 --- /dev/null +++ b/installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/pnpm-lock.yaml @@ -0,0 +1,45 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + injectWorkspacePackages: true + +importers: + + .: + dependencies: + pkg-a: + specifier: workspace:* + version: 'file:packages/pkg-a(peer@1.0.0)' + + packages/peer: {} + + packages/pkg-a: + peerDependencies: + peer: + specifier: 1.0.0 + version: 1.0.0 + +packages: + + # Base entry — has `resolution` describing the workspace directory. + 'pkg-a@file:packages/pkg-a': + resolution: { directory: packages/pkg-a, type: directory } + + # Peer-variant entry — inherits resolution from the base, intentionally + # omits its own `resolution`. This shape is what pnpm itself writes for a + # workspace dep with peer dependencies that resolve differently per + # consumer. Before the fix it crashed `lockfileToDepGraph` with + # `TypeError: Cannot use 'in' operator to search for 'directory' in undefined`. + 'pkg-a@file:packages/pkg-a(peer@1.0.0)': + dependencies: + peer: 1.0.0 + +snapshots: + + 'pkg-a@file:packages/pkg-a': {} + + 'pkg-a@file:packages/pkg-a(peer@1.0.0)': + dependencies: + peer: 1.0.0 diff --git a/installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/pnpm-workspace.yaml b/installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/pnpm-workspace.yaml new file mode 100644 index 0000000000..9af4682d1d --- /dev/null +++ b/installing/deps-restorer/test/fixtures/peer-variant-missing-resolution/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "packages/*" +injectWorkspacePackages: true diff --git a/installing/deps-restorer/test/index.ts b/installing/deps-restorer/test/index.ts index 518d40a1e1..94e05399a2 100644 --- a/installing/deps-restorer/test/index.ts +++ b/installing/deps-restorer/test/index.ts @@ -919,3 +919,25 @@ test('installing a package deeply installs all required dependencies', async () expect(projectAssertion.requireModule('is-positive')).toBeTruthy() } }) + +// Regression test: `lockfileToDepGraph` crashed with +// `TypeError: Cannot use 'in' operator to search for 'directory' in undefined` +// when a peer-dep variant snapshot in the lockfile omitted its `resolution` +// field. The variant intentionally inherits resolution from the base entry, +// so this shape is valid pnpm output but the graph builder accessed +// `pkgSnapshot.resolution` without guarding for undefined. +test('headlessInstall: peer-variant snapshot without `resolution` does not crash', async () => { + const workspaceFixture = f.prepare('peer-variant-missing-resolution') + const projects = [ + workspaceFixture, + path.join(workspaceFixture, 'packages', 'pkg-a'), + path.join(workspaceFixture, 'packages', 'peer'), + ] + + await expect( + headlessInstall(await testDefaults({ + lockfileDir: workspaceFixture, + projects, + })) + ).resolves.not.toThrow() +})