fix(graph-builder): guard against missing resolution on peer-variant snapshots

Peer-dep variant snapshots in the lockfile (e.g.
`pkg@file:packages/pkg(peer@2.0.0)`) inherit their resolution from the
base entry and may legally omit `resolution` themselves. The previous
code in `buildGraphFromPackages` accessed `pkgSnapshot.resolution`
directly with the `in` operator, which crashes with
`TypeError: Cannot use 'in' operator to search for 'directory' in undefined`
when `resolution` is unset.

Guarding the access mirrors the pattern in `@pnpm/deps.graph-hasher`,
which already handles missing resolution defensively. A snapshot
without `resolution` is treated as "not a directory dep" — the base
entry still drives directory-dep detection through its own snapshot.
This commit is contained in:
Eyal Mizrachi
2026-05-14 21:55:27 -04:00
parent ae703b1bcd
commit b77b5797ae
8 changed files with 102 additions and 1 deletions

View File

@@ -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).

View File

@@ -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)`,

View File

@@ -0,0 +1,8 @@
{
"name": "peer-variant-missing-resolution",
"version": "1.0.0",
"private": true,
"dependencies": {
"pkg-a": "workspace:*"
}
}

View File

@@ -0,0 +1,5 @@
{
"name": "peer",
"version": "1.0.0",
"private": true
}

View File

@@ -0,0 +1,8 @@
{
"name": "pkg-a",
"version": "1.0.0",
"private": true,
"peerDependencies": {
"peer": "1.0.0"
}
}

View File

@@ -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

View File

@@ -0,0 +1,3 @@
packages:
- "packages/*"
injectWorkspacePackages: true

View File

@@ -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()
})