fix: replace catalog: protocol in injected workspace dependencies (#9266)

* feat: return `workspace` for `resolvedVia`

* test: catalog protocol on injected deps

* refactor: create new `getCatalogReplacementPref` function

* refactor: pass through `resolvedVia` field to `ResolveDependencyResult`

* fix: replace `catalog:` protocol in injected workspace dependencies

---------

close #8715
This commit is contained in:
Brandon Cheng
2025-03-18 06:49:14 -04:00
committed by Zoltan Kochan
parent 936430a8a0
commit f0f95abfbb
3 changed files with 227 additions and 14 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/resolve-dependencies": patch
pnpm: patch
---
Fix usages of the [`catalog:` protocol](https://pnpm.io/catalogs) in [injected local workspace packages](https://pnpm.io/package_json#dependenciesmetainjected). This previously errored with `ERR_PNPM_SPEC_NOT_SUPPORTED_BY_ANY_RESOLVER`. [#8715](https://github.com/pnpm/pnpm/issues/8715)

View File

@@ -719,6 +719,162 @@ test('lockfile catalog snapshots should remove unused entries', async () => {
}
})
// Regression test for https://github.com/pnpm/pnpm/issues/8715
//
// Catalogs on injected deps require more consideration since the injected dep
// is no longer seen as an "importer". The catalog protocol is traditionally
// only for "importers" (i.e. packages matching the `packages` filter in
// pnpm-workspace.yaml).
//
// Since injected deps copy the workspace package into the node_modules/.pnpm
// dir, a bit more work has to be done to make catalogs usable on these unique
// packages.
//
// Example of a package at packages/project2 getting "injected".
//
// node_modules/.pnpm/project2@file+packages+project2/node_modules/project2
//
test('catalogs work in injected dep', async () => {
expect.hasAssertions()
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([
{
name: 'project1',
dependencies: {
project2: 'workspace:*',
},
dependenciesMeta: {
project2: { injected: true },
},
},
{
name: 'project2',
dependencies: {
'is-positive': 'catalog:',
},
},
])
const install = () => mutateModules(installProjects(projects), {
...options,
lockfileOnly: true,
// This setting turns injected deps into regular symlinked workspace
// packages if peer dependencies aren't resolved differently.
dedupeInjectedDeps: false,
catalogs: {
default: { 'is-positive': '1.0.0' },
},
})
// This should run without "is-positive@catalog: isn't supported by any
// available resolver." errors.
await expect(install()).resolves.not.toThrow()
const lockfile = readLockfile()
// The resolved catalogs should be correct.
expect(lockfile.catalogs).toStrictEqual({
default: {
'is-positive': { specifier: '1.0.0', version: '1.0.0' },
},
})
expect(lockfile.importers).toEqual({
// Check that project2 was indeed injected into project1. Otherwise this
// test wouldn't be checking the correct scenario.
project1: {
dependencies: {
project2: { specifier: 'workspace:*', version: 'file:project2' },
},
dependenciesMeta: {
project2: { injected: true },
},
},
project2: {
dependencies: {
'is-positive': { specifier: 'catalog:', version: '1.0.0' },
},
},
})
// Double check the correct version of is-positive as requested from the
// catalog was installed and not the latest.
expect(lockfile.snapshots).toStrictEqual({
'is-positive@1.0.0': {},
'project2@file:project2': {
dependencies: { 'is-positive': '1.0.0' },
},
})
})
test('catalogs work when inject-workspace-packages=true', async () => {
expect.hasAssertions()
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([
{
name: 'project1',
dependencies: {
project2: 'workspace:*',
},
},
{
name: 'project2',
dependencies: {
'is-positive': 'catalog:',
},
},
])
const install = () => mutateModules(installProjects(projects), {
...options,
lockfileOnly: true,
// This setting turns injected deps into regular symlinked workspace
// packages if peer dependencies aren't resolved differently.
dedupeInjectedDeps: false,
injectWorkspacePackages: true,
catalogs: {
default: { 'is-positive': '1.0.0' },
},
})
// This should run without "is-positive@catalog: isn't supported by any
// available resolver." errors.
await expect(install()).resolves.not.toThrow()
const lockfile = readLockfile()
// The resolved catalogs should be correct.
expect(lockfile.catalogs).toStrictEqual({
default: {
'is-positive': { specifier: '1.0.0', version: '1.0.0' },
},
})
expect(lockfile.importers).toEqual({
// Check that project2 was indeed injected into project1. Otherwise this
// test wouldn't be checking the correct scenario.
project1: {
dependencies: {
project2: { specifier: 'workspace:*', version: 'file:project2' },
},
},
project2: {
dependencies: {
'is-positive': { specifier: 'catalog:', version: '1.0.0' },
},
},
})
// Double check the correct version of is-positive as requested from the
// catalog was installed and not the latest.
expect(lockfile.snapshots).toStrictEqual({
'is-positive@1.0.0': {},
'project2@file:project2': {
dependencies: { 'is-positive': '1.0.0' },
},
})
})
describe('add', () => {
test('adding is-positive@catalog: works', async () => {
const { options, projects, readLockfile } = preparePackagesAndReturnObjects([{

View File

@@ -1,5 +1,5 @@
import path from 'path'
import { matchCatalogResolveResult, type CatalogResolver } from '@pnpm/catalogs.resolver'
import { type CatalogResolution, matchCatalogResolveResult, type CatalogResolver } from '@pnpm/catalogs.resolver'
import {
deprecationLogger,
progressLogger,
@@ -190,6 +190,7 @@ export type PkgAddress = {
depIsLinked: boolean
isNew: boolean
isLinkedDependency?: false
resolvedVia?: string
nodeId: NodeId
pkgId: PkgResolutionId
normalizedPref?: string // is returned only for root dependencies
@@ -250,7 +251,7 @@ export interface ResolvedPackage {
}
}
type ParentPkg = Pick<PkgAddress, 'nodeId' | 'installable' | 'rootDir' | 'optional' | 'pkgId'>
type ParentPkg = Pick<PkgAddress, 'nodeId' | 'installable' | 'rootDir' | 'optional' | 'pkgId' | 'resolvedVia'>
export type ParentPkgAliases = Record<string, PkgAddress | true>
@@ -551,18 +552,10 @@ async function resolveDependenciesOfImporterDependency (
const originalPref = extendedWantedDep.wantedDependency.pref
if (catalogLookup != null) {
// The lockfile from a previous installation may have already resolved this
// cataloged dependency. Reuse the exact version in the lockfile catalog
// snapshot to ensure all projects using the same cataloged dependency get
// the same version.
const existingCatalogResolution = ctx.wantedLockfile.catalogs
?.[catalogLookup.catalogName]
?.[extendedWantedDep.wantedDependency.alias]
const replacementPref = existingCatalogResolution?.specifier === catalogLookup.specifier
? replaceVersionInPref(catalogLookup.specifier, existingCatalogResolution.version)
: catalogLookup.specifier
extendedWantedDep.wantedDependency.pref = replacementPref
extendedWantedDep.wantedDependency.pref = getCatalogReplacementPref(
catalogLookup,
ctx.wantedLockfile,
extendedWantedDep.wantedDependency)
}
const result = await resolveDependenciesOfDependency(
@@ -830,6 +823,44 @@ async function resolveDependenciesOfDependency (
supportedArchitectures: options.supportedArchitectures,
parentIds: options.parentIds,
}
// The catalog protocol is normally replaced when resolving the dependencies
// of importers. However, when a workspace package is "injected", it becomes a
// "file:" dependency and is no longer an "importer" from the perspective of
// pnpm.
//
// To allow the catalog protocol to still be used for injected workspace
// packages, it's necessary to check if the parent package was an injected
// workspace package and replace the catalog: protocol for the current package.
const isInjectedWorkspacePackage = options.parentPkg.resolvedVia === 'workspace' &&
options.parentPkg.pkgId.startsWith('file:')
if (isInjectedWorkspacePackage) {
const catalogLookup = matchCatalogResolveResult(ctx.catalogResolver(extendedWantedDep.wantedDependency), {
found: (result) => result.resolution,
unused: () => undefined,
misconfiguration: (result) => {
throw result.error
},
})
// The standard process for replacing the catalog protocol when resolving
// the dependencies of "importers" stores the catalog lookup in the
// dependency resolution result. This allows the catalogs snapshot section
// of the wanted lockfile to be kept up to date.
//
// We can do a simple replacement here instead and discard the catalog
// lookup object. It's not necessary to store this information for injected
// workspace packages. The injected workspace package will still be resolved
// as an importer separately, and we can rely on that process keeping the
// importers lockfile catalog snapshots up to date.
if (catalogLookup != null) {
extendedWantedDep.wantedDependency.pref = getCatalogReplacementPref(
catalogLookup,
ctx.wantedLockfile,
extendedWantedDep.wantedDependency)
}
}
const resolveDependencyResult = await resolveDependency(extendedWantedDep.wantedDependency, ctx, resolveDependencyOpts)
if (resolveDependencyResult == null) return { resolveDependencyResult: null }
@@ -1530,6 +1561,7 @@ async function resolveDependency (
return {
alias: wantedDependency.alias || pkg.name,
depIsLinked,
resolvedVia: pkgResponse.body.resolvedVia,
isNew,
nodeId,
normalizedPref: options.currentDepth === 0 ? pkgResponse.body.normalizedPref : undefined,
@@ -1651,3 +1683,22 @@ function peerDependenciesWithoutOwn (pkg: PackageManifest): PeerDependencies {
}
return result
}
function getCatalogReplacementPref (
catalogLookup: CatalogResolution,
wantedLockfile: LockfileObject,
wantedDependency: WantedDependency
): string {
// The lockfile from a previous installation may have already resolved this
// cataloged dependency. Reuse the exact version in the lockfile catalog
// snapshot to ensure all projects using the same cataloged dependency get the
// same version.
const existingCatalogResolution = wantedLockfile.catalogs
?.[catalogLookup.catalogName]
?.[wantedDependency.alias]
const replacementPref = existingCatalogResolution?.specifier === catalogLookup.specifier
? replaceVersionInPref(catalogLookup.specifier, existingCatalogResolution.version)
: catalogLookup.specifier
return replacementPref
}