feat(outdated): include node, deno, and bun runtimes (#11739)

`pnpm outdated` and `pnpm update --interactive` previously skipped runtime dependencies (`node`/`deno`/`bun` installed via the `runtime:` protocol). Both commands go through `outdatedDepsOfProjects` → `outdated()`, and the inner loop bailed out for anything `parseBareSpecifier` couldn't parse — which is everything `runtime:`-shaped. A runtime was only ever reported if the current install differed from the wanted lockfile entry, so the latest available version was never surfaced. The same gap silently affected `jsr:` and named-registry deps too.

Commits, smallest fix first → progressively cleaner architecture:

1. **`feat(outdated)`** — minimal fix: special-case runtime deps in `outdated.ts` so they appear in the table and the interactive update picker.
2. **`refactor(outdated)`** — per-resolver dispatch. Each protocol resolver gets its own "what's the latest?" function; `@pnpm/resolving.default-resolver` composes them.
3. **`refactor(outdated)`** — rename to `resolveLatest` (the function returns info regardless of whether the dep is outdated; "outdated" described a state, not an action).
4. **`refactor(outdated)`** — let the local-resolver own the `link:`/`file:` skip, drop the matching short-circuit in `outdated.ts`.
5. **`refactor(outdated)`** — slim `LatestQuery` / `LatestInfo` to the bare essentials; move `pickRegistryForPackage` into the npm-resolver where it belongs; derive `current`/`wanted` display from `pkgSnapshot.version` in `outdated.ts`.
6. **`chore(outdated)`** — drop stale tsconfig project reference left behind by #5.
7. **`refactor(outdated)`** — drop `wantedRef` from the query; resolvers detect protocol from `bareSpecifier` alone.

## Final architecture

`@pnpm/resolving.resolver-base` defines a single tiny protocol:

```ts
interface LatestQuery {
  wantedDependency: WantedDependency
  compatible?: boolean
}

interface LatestInfo {
  latestManifest?: PackageManifest
}

type ResolveLatestFunction = (query: LatestQuery, opts: ResolveOptions) =>
  Promise<LatestInfo | undefined>
```

- `undefined` from a resolver means "I don't claim this dep — try the next one."
- `{}` means "I claim it, but I can't tell you what's latest" (policy-blocked, network unavailable, or a protocol with no concept of latest — git/tarball).
- `{ latestManifest }` is the happy path.

Each protocol resolver (npm/jsr/named-registry, git, tarball, local, node/bun/deno runtimes) exports its own `resolveLatest*` function alongside its `resolve*`. `@pnpm/resolving.default-resolver` composes them into a single first-match dispatcher, surfaced through `@pnpm/installing.client` as `createResolver(...).resolveLatest`.

`outdated.ts` is protocol-agnostic: dispatches, then derives `current`/`wanted` display from `pkgSnapshot.version` (falling back to the raw ref for URL-shaped refs where the URL is the only diff signal between commits), uses raw `wantedRef !== currentRef` for the lockfile-shifted check, and pulls `packageName` from `dp.parse(relativeDepPath).name` so aliased deps still report under the real package name.

Per-resolver responsibilities:
- **npm-resolver** (`resolveLatestFromNpm` / `resolveLatestFromJsr` / `resolveLatestFromNamedRegistry`): match their respective spec shapes, call the matching `resolveFromX` with `'latest'` (or the original spec under `--compatible`), handle `MINIMUM_RELEASE_AGE_VIOLATION` and `ERR_PNPM_NO_MATCHING_VERSION` so policy-blocked deps don't surface as available updates. Picks the per-package registry internally via its ctx.
- **node/bun/deno runtime resolvers**: claim deps via `bareSpecifier.startsWith('runtime:')` + alias match, query their release sources for the latest version (only the version — no asset-hash fetches), return `{ latestManifest }`.
- **git / tarball resolvers**: claim deps via spec shape, return `{}` (no concept of "latest"); the caller still surfaces a ref-mismatch report if the lockfile shifted to a different commit/URL.
- **local-resolver**: returns `undefined` so `link:`/`file:`/`workspace:` deps fall through and get silently skipped.
This commit is contained in:
Zoltan Kochan
2026-05-19 19:15:07 +02:00
committed by GitHub
parent 9a8675d3f1
commit 1627943d2a
18 changed files with 625 additions and 132 deletions

View File

@@ -0,0 +1,21 @@
---
"@pnpm/deps.inspection.outdated": minor
"@pnpm/installing.client": minor
"@pnpm/resolving.default-resolver": minor
"@pnpm/resolving.resolver-base": minor
"@pnpm/resolving.npm-resolver": minor
"@pnpm/resolving.git-resolver": minor
"@pnpm/resolving.tarball-resolver": minor
"@pnpm/resolving.local-resolver": minor
"@pnpm/engine.runtime.node-resolver": minor
"@pnpm/engine.runtime.bun-resolver": minor
"@pnpm/engine.runtime.deno-resolver": minor
"@pnpm/pkg-manifest.utils": minor
"pnpm": minor
---
`pnpm outdated` and `pnpm update --interactive` now report Node.js, Deno, and Bun runtimes installed as project dependencies (`runtime:` specifiers). Previously these were silently skipped because the npm specifier parser did not understand the `runtime:` protocol, so runtime versions never appeared in the outdated table or the interactive update picker.
Internally, the outdated check is now resolver-driven: `@pnpm/resolving.resolver-base` defines a `ResolveLatestFunction` shape (with `LatestQuery` input — `{ wantedDependency, compatible? }` — and `LatestInfo` result — `{ latestManifest? }`), and every protocol resolver (npm, jsr, named-registry, git, tarball, local, node/bun/deno runtimes) exports its own `resolveLatest*` function alongside its `resolve*`. `@pnpm/resolving.default-resolver` composes them into a single dispatcher, exposed through `@pnpm/installing.client` as `createResolver(...).resolveLatest`.
Each resolver decides whether it owns the dep and what "latest" means for its protocol; the outdated command derives `current` / `wanted` display values from the lockfile snapshot (`pkgSnapshot.version` for semver protocols, raw ref for URL-shaped ones) and uses raw ref equality for the "lockfile changed" check, so protocol knowledge stays inside each resolver instead of the command.

View File

@@ -36,7 +36,6 @@
"@pnpm/catalogs.types": "workspace:*",
"@pnpm/config.matcher": "workspace:*",
"@pnpm/config.parse-overrides": "workspace:*",
"@pnpm/config.pick-registry-for-package": "workspace:*",
"@pnpm/config.version-policy": "workspace:*",
"@pnpm/constants": "workspace:*",
"@pnpm/deps.path": "workspace:*",

View File

@@ -7,33 +7,30 @@ import {
import type { Catalogs } from '@pnpm/catalogs.types'
import { createMatcher } from '@pnpm/config.matcher'
import { parseOverrides } from '@pnpm/config.parse-overrides'
import { pickRegistryForPackage } from '@pnpm/config.pick-registry-for-package'
import { LOCKFILE_VERSION, WANTED_LOCKFILE } from '@pnpm/constants'
import { LOCKFILE_VERSION } from '@pnpm/constants'
import * as dp from '@pnpm/deps.path'
import { PnpmError } from '@pnpm/error'
import { createReadPackageHook } from '@pnpm/hooks.read-package-hook'
import type { ResolveLatestDispatcher } from '@pnpm/installing.client'
import {
getLockfileImporterId,
type LockfileObject,
type ProjectSnapshot,
} from '@pnpm/lockfile.fs'
import { nameVerFromPkgSnapshot } from '@pnpm/lockfile.utils'
import { getAllDependenciesFromManifest } from '@pnpm/pkg-manifest.utils'
import { parseBareSpecifier } from '@pnpm/resolving.npm-resolver'
import {
DEPENDENCIES_FIELDS,
type DependenciesField,
type DepPath,
type IncludedDependencies,
type PackageManifest,
type PackageVersionPolicy,
type ProjectManifest,
type Registries,
} from '@pnpm/types'
import semver from 'semver'
export * from './createManifestGetter.js'
export type GetLatestManifestFunction = (packageName: string, rangeOrTag: string) => Promise<PackageManifest | null>
export interface OutdatedPackage {
alias: string
belongsTo: DependenciesField
@@ -49,7 +46,7 @@ export async function outdated (
catalogs?: Catalogs
compatible?: boolean
currentLockfile: LockfileObject | null
getLatestManifest: GetLatestManifestFunction
resolveLatest: ResolveLatestDispatcher
ignoreDependencies?: string[]
include?: IncludedDependencies
lockfileDir: string
@@ -58,7 +55,8 @@ export async function outdated (
minimumReleaseAge?: number
minimumReleaseAgeExclude?: string[]
prefix: string
registries: Registries
publishedBy?: Date
publishedByExclude?: PackageVersionPolicy
wantedLockfile: LockfileObject | null
}
): Promise<OutdatedPackage[]> {
@@ -89,6 +87,14 @@ export async function outdated (
const ignoreDependenciesMatcher = opts.ignoreDependencies?.length ? createMatcher(opts.ignoreDependencies) : undefined
const resolveOpts = {
lockfileDir: opts.lockfileDir,
preferredVersions: {},
projectDir: opts.prefix,
publishedBy: opts.publishedBy,
publishedByExclude: opts.publishedByExclude,
}
await Promise.all(
DEPENDENCIES_FIELDS.map(async (depType) => {
if (
@@ -107,41 +113,37 @@ export async function outdated (
await Promise.all(
pkgs.map(async (alias) => {
if (!allDeps[alias]) return
const ref = opts.wantedLockfile!.importers[importerId][depType]![alias]
if (
ref.startsWith('file:') || // ignoring linked packages. (For backward compatibility)
ignoreDependenciesMatcher?.(alias)
) {
return
}
const relativeDepPath = dp.refToRelative(ref, alias)
// ignoring linked packages
if (relativeDepPath === null) return
const pkgSnapshot = opts.wantedLockfile!.packages?.[relativeDepPath]
if (pkgSnapshot == null) {
throw new Error(`Invalid ${WANTED_LOCKFILE} file. ${relativeDepPath} not found in packages field`)
}
const wantedRef = opts.wantedLockfile!.importers[importerId][depType]![alias]
if (ignoreDependenciesMatcher?.(alias)) return
const currentRef = (currentLockfile.importers[importerId] as ProjectSnapshot)?.[depType]?.[alias]
const currentRelative = currentRef && dp.refToRelative(currentRef, alias)
const current = (currentRelative && dp.parse(currentRelative).version) ?? currentRef
const wanted = dp.parse(relativeDepPath).version ?? ref
const { name: packageName } = nameVerFromPkgSnapshot(relativeDepPath, pkgSnapshot)
const name = dp.parse(relativeDepPath).name ?? packageName
const wantedRelative = dp.refToRelative(wantedRef, alias)
const currentRelative = currentRef ? dp.refToRelative(currentRef, alias) : null
const wantedSnapshot = wantedRelative != null ? opts.wantedLockfile!.packages?.[wantedRelative] : undefined
const currentSnapshot = currentRelative != null ? currentLockfile.packages?.[currentRelative] : undefined
// Aliased npm deps lock under their real name (e.g. `positive: is-positive@3.1.0`);
// pull the name off the depPath so the report shows the real package.
const packageName = (wantedRelative != null ? dp.parse(wantedRelative).name : undefined) ?? alias
const bareSpecifier = _replaceCatalogProtocolIfNecessary({ alias, bareSpecifier: allDeps[alias] })
// If the npm resolve parser cannot parse the spec of the dependency,
// it means that the package is not from a npm-compatible registry.
// In that case, we can't check whether the package is up-to-date
if (
parseBareSpecifier(bareSpecifier, alias, 'latest', pickRegistryForPackage(opts.registries, name)) == null
) {
if (current !== wanted) {
const info = await opts.resolveLatest(
{ wantedDependency: { alias, bareSpecifier }, compatible: opts.compatible },
resolveOpts
)
if (info == null) return // resolver doesn't claim this dep — skip silently
const wanted = displayVersion(wantedRef, wantedRelative, wantedSnapshot?.version)
const current = currentRef ? displayVersion(currentRef, currentRelative, currentSnapshot?.version) : undefined
const { latestManifest } = info
// Compare the parsed `wanted` / `current` rather than raw refs.
// For npm-style deps that means peer-graph-only changes (same
// semver, different `(peer-hash)`) don't surface as fake
// "outdated" entries; for URL/git refs the display values *are*
// the refs, so a commit/path change still fires correctly.
if (latestManifest == null) {
if (wanted !== current) {
outdated.push({
alias,
belongsTo: depType,
@@ -154,14 +156,6 @@ export async function outdated (
}
return
}
const latestManifest = await opts.getLatestManifest(
name,
opts.compatible ? (bareSpecifier ?? 'latest') : 'latest'
)
if (latestManifest == null) return
if (!current) {
outdated.push({
alias,
@@ -170,12 +164,10 @@ export async function outdated (
packageName,
wanted,
workspace: opts.manifest.name,
})
return
}
if (current !== wanted || semver.lt(current, latestManifest.version) || latestManifest.deprecated) {
if (wanted !== current || isLowerVersion(wanted, latestManifest.version) || latestManifest.deprecated) {
outdated.push({
alias,
belongsTo: depType,
@@ -184,7 +176,6 @@ export async function outdated (
packageName,
wanted,
workspace: opts.manifest.name,
})
}
})
@@ -205,6 +196,33 @@ function isEmpty (obj: object): boolean {
return Object.keys(obj).length === 0
}
// Pick a clean display string for a lockfile ref.
//
// - If the dep-path parses to a semver, that's the value (handles
// `pkg@1.0.0(peer-hash)` and aliased `positive: is-positive@3.1.0`).
// - If the dep-path's non-semver version contains a `/`, it's a
// URL/git-shape (`https://`, `git+ssh://`, scheme-less `github.com/…/sha`,
// `link:../foo`, etc.) — return the raw ref so a commit/path change is
// visible to the user.
// - Otherwise prefer `snapshot.version` (clean semver for `runtime:`-style
// refs); fall back to the raw ref when the snapshot didn't record one.
function displayVersion (ref: string, relativeDepPath: DepPath | null, snapshotVersion: string | undefined): string {
if (relativeDepPath != null) {
const parsed = dp.parse(relativeDepPath)
if (parsed.version != null) return parsed.version
if (parsed.nonSemverVersion?.includes('/')) return ref
}
return snapshotVersion ?? ref
}
// semver.lt throws on non-semver strings (e.g. URL refs from git/tarball).
// Treat those as "not lower" so a ref change still gets surfaced via the
// `wantedRef !== currentRef` check above.
function isLowerVersion (current: string, latest: string): boolean {
if (!semver.valid(current) || !semver.valid(latest)) return false
return semver.lt(current, latest)
}
function replaceCatalogProtocolIfNecessary (catalogs: Catalogs, wantedDependency: WantedDependency) {
return matchCatalogResolveResult(resolveFromCatalog(catalogs, wantedDependency), {
unused: () => wantedDependency.bareSpecifier,

View File

@@ -2,6 +2,8 @@ import path from 'node:path'
import type { Catalogs } from '@pnpm/catalogs.types'
import { createMatcher } from '@pnpm/config.matcher'
import { getPublishedByPolicy } from '@pnpm/config.version-policy'
import { type ClientOptions, createResolver } from '@pnpm/installing.client'
import {
readCurrentLockfile,
readWantedLockfile,
@@ -10,23 +12,33 @@ import type {
IncludedDependencies,
ProjectManifest,
ProjectRootDir,
RegistryConfig,
} from '@pnpm/types'
import { unnest } from 'ramda'
import { createManifestGetter, type ManifestGetterOptions } from './createManifestGetter.js'
import { outdated, type OutdatedPackage } from './outdated.js'
export type OutdatedDepsOfProjectsOptions = Omit<ClientOptions, 'configByUri' | 'minimumReleaseAgeExclude' | 'storeIndex'>
& {
dir: string
lockfileDir?: string
configByUri: Record<string, RegistryConfig>
fullMetadata?: boolean
minimumReleaseAge?: number
minimumReleaseAgeExclude?: string[]
minimumReleaseAgeIgnoreMissingTime?: boolean
minimumReleaseAgeStrict?: boolean
}
export async function outdatedDepsOfProjects (
pkgs: Array<{ rootDir: ProjectRootDir, manifest: ProjectManifest }>,
args: string[],
opts: Omit<ManifestGetterOptions, 'fullMetadata' | 'lockfileDir'> & {
opts: OutdatedDepsOfProjectsOptions & {
catalogs?: Catalogs
compatible?: boolean
ignoreDependencies?: string[]
include: IncludedDependencies
minimumReleaseAge?: number
minimumReleaseAgeExclude?: string[]
} & Partial<Pick<ManifestGetterOptions, 'fullMetadata' | 'lockfileDir'>>
}
): Promise<OutdatedPackage[][]> {
if (!opts.lockfileDir) {
return unnest(await Promise.all(
@@ -39,20 +51,23 @@ export async function outdatedDepsOfProjects (
const internalPnpmDir = path.join(path.join(lockfileDir, 'node_modules/.pnpm'))
const currentLockfile = await readCurrentLockfile(internalPnpmDir, { ignoreIncompatible: false })
const wantedLockfile = await readWantedLockfile(lockfileDir, { ignoreIncompatible: false }) ?? currentLockfile
const getLatestManifest = createManifestGetter({
const { publishedBy, publishedByExclude } = getPublishedByPolicy(opts)
const { resolveLatest } = createResolver({
...opts,
configByUri: opts.configByUri,
filterMetadata: false,
fullMetadata: opts.fullMetadata === true || Boolean(opts.minimumReleaseAge),
lockfileDir,
minimumReleaseAge: opts.minimumReleaseAge,
minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude,
ignoreMissingTimeField: opts.minimumReleaseAgeIgnoreMissingTime,
})
return Promise.all(pkgs.map(async ({ rootDir, manifest }): Promise<OutdatedPackage[]> => {
const match = (args.length > 0) && createMatcher(args) || undefined
return outdated({
catalogs: opts.catalogs,
compatible: opts.compatible,
currentLockfile,
getLatestManifest,
resolveLatest,
ignoreDependencies: opts.ignoreDependencies,
include: opts.include,
lockfileDir,
@@ -61,7 +76,8 @@ export async function outdatedDepsOfProjects (
minimumReleaseAge: opts.minimumReleaseAge,
minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude,
prefix: rootDir,
registries: opts.registries,
publishedBy,
publishedByExclude,
wantedLockfile,
})
}))

View File

@@ -1,10 +1,44 @@
import { expect, test } from '@jest/globals'
import { LOCKFILE_VERSION } from '@pnpm/constants'
import type { DepPath, ProjectId } from '@pnpm/types'
import type { ResolveLatestDispatcher } from '@pnpm/installing.client'
import type { DepPath, PackageManifest, ProjectId } from '@pnpm/types'
import { outdated } from '../lib/outdated.js'
async function getLatestManifest (packageName: string) {
type ManifestGetter = (packageName: string) => Promise<PackageManifest | null>
// Test stand-in for the real dispatcher: route by protocol shape, look up
// "latest" through the per-test mock. Resolvers for non-latest protocols
// (git, tarball) return `{}` to claim the dep with no latest available;
// local refs return undefined so the dispatcher falls through and skips.
function makeResolveLatest (getLatest: ManifestGetter): ResolveLatestDispatcher {
return async (query) => {
const { alias, bareSpecifier } = query.wantedDependency
if (bareSpecifier?.startsWith('file:') || bareSpecifier?.startsWith('link:') || bareSpecifier?.startsWith('workspace:')) {
return undefined
}
if (bareSpecifier?.startsWith('runtime:') && (alias === 'node' || alias === 'bun' || alias === 'deno')) {
const latestManifest = await getLatest(alias)
return { latestManifest: latestManifest ?? undefined }
}
if (
bareSpecifier?.startsWith('http://') || bareSpecifier?.startsWith('https://') ||
bareSpecifier?.startsWith('github:') || bareSpecifier?.startsWith('git+') || bareSpecifier?.startsWith('git:')
) {
return {}
}
let pkgName = alias ?? ''
if (bareSpecifier?.startsWith('npm:')) {
const inner = bareSpecifier.slice(4)
const atIdx = inner.lastIndexOf('@')
pkgName = (atIdx > 0 ? inner.slice(0, atIdx) : inner) || pkgName
}
const latestManifest = await getLatest(pkgName)
return { latestManifest: latestManifest ?? undefined }
}
}
async function getLatestManifest (packageName: string): Promise<PackageManifest | null> {
return ({
'deprecated-pkg': {
deprecated: 'This package is deprecated',
@@ -23,9 +57,11 @@ async function getLatestManifest (packageName: string) {
name: 'pkg-with-1-dep',
version: '1.0.0',
},
})[packageName] ?? null
} as Record<string, PackageManifest>)[packageName] ?? null
}
const resolveLatest = makeResolveLatest(getLatestManifest)
test('outdated()', async () => {
const outdatedPkgs = await outdated({
currentLockfile: {
@@ -70,7 +106,7 @@ test('outdated()', async () => {
},
},
},
getLatestManifest,
resolveLatest,
lockfileDir: 'project',
manifest: {
name: 'wanted-shrinkwrap',
@@ -136,9 +172,6 @@ test('outdated()', async () => {
},
},
},
registries: {
default: 'https://registry.npmjs.org/',
},
})
expect(outdatedPkgs).toStrictEqual([
{
@@ -209,7 +242,7 @@ test('outdated() should return deprecated package even if its current version is
}
const outdatedPkgs = await outdated({
currentLockfile: lockfile,
getLatestManifest,
resolveLatest,
lockfileDir: 'project',
manifest: {
name: 'wanted-shrinkwrap',
@@ -221,9 +254,6 @@ test('outdated() should return deprecated package even if its current version is
},
prefix: 'project',
wantedLockfile: lockfile,
registries: {
default: 'https://registry.npmjs.org/',
},
})
expect(outdatedPkgs).toStrictEqual([
{
@@ -286,7 +316,7 @@ test('outdated() with minimumReleaseAge', async () => {
},
},
},
getLatestManifest: getLatestManifestForMinimumAge,
resolveLatest: makeResolveLatest(getLatestManifestForMinimumAge),
lockfileDir: 'project',
manifest: {
name: 'with-min-age',
@@ -324,9 +354,6 @@ test('outdated() with minimumReleaseAge', async () => {
},
},
},
registries: {
default: 'https://registry.npmjs.org/',
},
minimumReleaseAge: 10080,
})
@@ -402,7 +429,7 @@ test('outdated() with minimumReleaseAgeExclude', async () => {
},
},
},
getLatestManifest: getLatestManifestWithExclude,
resolveLatest: makeResolveLatest(getLatestManifestWithExclude),
lockfileDir: 'project',
manifest: {
name: 'with-exclude',
@@ -440,9 +467,6 @@ test('outdated() with minimumReleaseAgeExclude', async () => {
},
},
},
registries: {
default: 'https://registry.npmjs.org/',
},
minimumReleaseAge: 10080,
minimumReleaseAgeExclude: ['is-negative'],
})
@@ -515,7 +539,7 @@ test('using a matcher', async () => {
},
},
},
getLatestManifest,
resolveLatest,
lockfileDir: 'wanted-shrinkwrap',
manifest: {
name: 'wanted-shrinkwrap',
@@ -575,9 +599,6 @@ test('using a matcher', async () => {
},
},
},
registries: {
default: 'https://registry.npmjs.org/',
},
})
expect(outdatedPkgs).toStrictEqual([
{
@@ -617,7 +638,7 @@ test('outdated() aliased dependency', async () => {
},
},
},
getLatestManifest,
resolveLatest,
lockfileDir: 'project',
manifest: {
name: 'wanted-shrinkwrap',
@@ -648,9 +669,6 @@ test('outdated() aliased dependency', async () => {
},
},
},
registries: {
default: 'https://registry.npmjs.org/',
},
})
expect(outdatedPkgs).toStrictEqual([
{
@@ -705,7 +723,7 @@ test('a dependency is not outdated if it is newer than the latest version', asyn
}
const outdatedPkgs = await outdated({
currentLockfile: lockfile,
getLatestManifest: async (packageName) => {
resolveLatest: makeResolveLatest( async (packageName) => {
switch (packageName) {
case 'foo':
return {
@@ -724,7 +742,7 @@ test('a dependency is not outdated if it is newer than the latest version', asyn
}
}
return null
},
}),
lockfileDir: 'project',
manifest: {
name: 'pkg',
@@ -738,9 +756,6 @@ test('a dependency is not outdated if it is newer than the latest version', asyn
},
prefix: 'project',
wantedLockfile: lockfile,
registries: {
default: 'https://registry.npmjs.org/',
},
})
expect(outdatedPkgs).toStrictEqual([])
})
@@ -748,9 +763,9 @@ test('a dependency is not outdated if it is newer than the latest version', asyn
test('outdated() should [] when there is no dependency', async () => {
const outdatedPkgs = await outdated({
currentLockfile: null,
getLatestManifest: async () => {
resolveLatest: makeResolveLatest( async () => {
return null
},
}),
lockfileDir: 'project',
manifest: {
name: 'pkg',
@@ -758,9 +773,6 @@ test('outdated() should [] when there is no dependency', async () => {
},
prefix: 'project',
wantedLockfile: null,
registries: {
default: 'https://registry.npmjs.org/',
},
})
expect(outdatedPkgs).toStrictEqual([])
})
@@ -810,7 +822,7 @@ test('should ignore dependencies as expected', async () => {
},
},
},
getLatestManifest,
resolveLatest,
lockfileDir: 'project',
manifest: {
name: 'wanted-shrinkwrap',
@@ -876,9 +888,6 @@ test('should ignore dependencies as expected', async () => {
},
},
},
registries: {
default: 'https://registry.npmjs.org/',
},
ignoreDependencies: [
'from-*',
'is-negative',
@@ -899,3 +908,182 @@ test('should ignore dependencies as expected', async () => {
},
])
})
test('outdated() lists outdated runtimes (node, deno, bun)', async () => {
const runtimeLatestManifest = async (packageName: string) => {
return ({
node: { name: 'node', version: '23.0.0' },
deno: { name: 'deno', version: '2.5.0' },
bun: { name: 'bun', version: '1.1.42' },
} as Record<string, PackageManifest>)[packageName] ?? null
}
const lockfile = {
importers: {
['.' as ProjectId]: {
dependencies: {
node: 'runtime:22.0.0',
deno: 'runtime:2.4.2',
},
devDependencies: {
bun: 'runtime:1.1.40',
},
specifiers: {
node: 'runtime:^22.0.0',
deno: 'runtime:2.4.2',
bun: 'runtime:^1.1.0',
},
},
},
lockfileVersion: LOCKFILE_VERSION,
packages: {
['node@runtime:22.0.0' as DepPath]: {
version: '22.0.0',
resolution: { type: 'variations', variants: [] } as any, // eslint-disable-line @typescript-eslint/no-explicit-any
},
['deno@runtime:2.4.2' as DepPath]: {
version: '2.4.2',
resolution: { type: 'variations', variants: [] } as any, // eslint-disable-line @typescript-eslint/no-explicit-any
},
['bun@runtime:1.1.40' as DepPath]: {
version: '1.1.40',
resolution: { type: 'variations', variants: [] } as any, // eslint-disable-line @typescript-eslint/no-explicit-any
},
},
}
const outdatedPkgs = await outdated({
currentLockfile: lockfile,
resolveLatest: makeResolveLatest(runtimeLatestManifest),
lockfileDir: 'project',
manifest: {
name: 'has-runtimes',
version: '1.0.0',
dependencies: {
node: 'runtime:^22.0.0',
deno: 'runtime:2.4.2',
},
devDependencies: {
bun: 'runtime:^1.1.0',
},
},
prefix: 'project',
wantedLockfile: lockfile,
})
expect(outdatedPkgs).toStrictEqual([
{
alias: 'bun',
belongsTo: 'devDependencies',
current: '1.1.40',
latestManifest: { name: 'bun', version: '1.1.42' },
packageName: 'bun',
wanted: '1.1.40',
workspace: 'has-runtimes',
},
{
alias: 'deno',
belongsTo: 'dependencies',
current: '2.4.2',
latestManifest: { name: 'deno', version: '2.5.0' },
packageName: 'deno',
wanted: '2.4.2',
workspace: 'has-runtimes',
},
{
alias: 'node',
belongsTo: 'dependencies',
current: '22.0.0',
latestManifest: { name: 'node', version: '23.0.0' },
packageName: 'node',
wanted: '22.0.0',
workspace: 'has-runtimes',
},
])
})
test('outdated() runtime in --compatible mode resolves within the declared range', async () => {
const getLatestForCompat = async (packageName: string): Promise<PackageManifest | null> => {
expect(packageName).toBe('node')
return { name: 'node', version: '22.5.0' }
}
const lockfile = {
importers: {
['.' as ProjectId]: {
dependencies: { node: 'runtime:22.0.0' },
specifiers: { node: 'runtime:^22.0.0' },
},
},
lockfileVersion: LOCKFILE_VERSION,
packages: {
['node@runtime:22.0.0' as DepPath]: {
version: '22.0.0',
resolution: { type: 'variations', variants: [] } as any, // eslint-disable-line @typescript-eslint/no-explicit-any
},
},
}
const outdatedPkgs = await outdated({
compatible: true,
currentLockfile: lockfile,
resolveLatest: makeResolveLatest(getLatestForCompat),
lockfileDir: 'project',
manifest: {
name: 'with-runtime-range',
version: '1.0.0',
dependencies: { node: 'runtime:^22.0.0' },
},
prefix: 'project',
wantedLockfile: lockfile,
})
expect(outdatedPkgs).toStrictEqual([
{
alias: 'node',
belongsTo: 'dependencies',
current: '22.0.0',
latestManifest: { name: 'node', version: '22.5.0' },
packageName: 'node',
wanted: '22.0.0',
workspace: 'with-runtime-range',
},
])
})
test('outdated() does not list runtime that is already up to date', async () => {
const getLatestManifestUpToDate = async (packageName: string) => {
return packageName === 'node' ? { name: 'node', version: '22.0.0' } : null
}
const lockfile = {
importers: {
['.' as ProjectId]: {
dependencies: { node: 'runtime:22.0.0' },
specifiers: { node: 'runtime:22.0.0' },
},
},
lockfileVersion: LOCKFILE_VERSION,
packages: {
['node@runtime:22.0.0' as DepPath]: {
version: '22.0.0',
resolution: { type: 'variations', variants: [] } as any, // eslint-disable-line @typescript-eslint/no-explicit-any
},
},
}
const outdatedPkgs = await outdated({
currentLockfile: lockfile,
resolveLatest: makeResolveLatest(getLatestManifestUpToDate),
lockfileDir: 'project',
manifest: {
name: 'up-to-date',
version: '1.0.0',
dependencies: { node: 'runtime:22.0.0' },
},
prefix: 'project',
wantedLockfile: lockfile,
})
expect(outdatedPkgs).toStrictEqual([])
})

View File

@@ -21,9 +21,6 @@
{
"path": "../../../config/parse-overrides"
},
{
"path": "../../../config/pick-registry-for-package"
},
{
"path": "../../../config/version-policy"
},

View File

@@ -1,9 +1,11 @@
import { fetchShasumsFile } from '@pnpm/crypto.shasums-file'
import { PnpmError } from '@pnpm/error'
import type { FetchFromRegistry } from '@pnpm/fetching.types'
import type { NpmResolver } from '@pnpm/resolving.npm-resolver'
import { MINIMUM_RELEASE_AGE_VIOLATION_CODE, type NpmResolver } from '@pnpm/resolving.npm-resolver'
import type {
BinaryResolution,
LatestInfo,
LatestQuery,
PlatformAssetResolution,
PlatformAssetTarget,
ResolveOptions,
@@ -65,6 +67,30 @@ export async function resolveBunRuntime (
}
}
export async function resolveLatestBunRuntime (
ctx: { resolveFromNpm: NpmResolver },
query: LatestQuery,
opts: ResolveOptions
): Promise<LatestInfo | undefined> {
const manifestSpec = query.wantedDependency.bareSpecifier
if (query.wantedDependency.alias !== 'bun' || !manifestSpec?.startsWith('runtime:')) return undefined
const versionSpec = query.compatible ? manifestSpec.substring('runtime:'.length) : 'latest'
try {
const npmResolution = await ctx.resolveFromNpm(
{ alias: 'bun', bareSpecifier: versionSpec },
query.compatible ? opts : { ...opts, update: 'latest' }
)
if (npmResolution?.policyViolation?.code === MINIMUM_RELEASE_AGE_VIOLATION_CODE) return {}
if (!npmResolution?.manifest) return {}
return { latestManifest: { name: 'bun', version: npmResolution.manifest.version } }
} catch (err) {
if (opts.publishedBy && (err as { code?: string }).code === 'ERR_PNPM_NO_MATCHING_VERSION') {
return {}
}
throw err
}
}
async function readBunAssets (fetch: FetchFromRegistry, version: string): Promise<PlatformAssetResolution[]> {
const integritiesFileUrl = `https://github.com/oven-sh/bun/releases/download/bun-v${version}/SHASUMS256.txt`
const shasumsFileItems = await fetchShasumsFile(fetch, integritiesFileUrl)

View File

@@ -1,8 +1,10 @@
import { PnpmError } from '@pnpm/error'
import type { FetchFromRegistry } from '@pnpm/fetching.types'
import type { NpmResolver } from '@pnpm/resolving.npm-resolver'
import { MINIMUM_RELEASE_AGE_VIOLATION_CODE, type NpmResolver } from '@pnpm/resolving.npm-resolver'
import type {
BinaryResolution,
LatestInfo,
LatestQuery,
PlatformAssetResolution,
PlatformAssetTarget,
ResolveOptions,
@@ -96,6 +98,30 @@ export async function resolveDenoRuntime (
}
}
export async function resolveLatestDenoRuntime (
ctx: { resolveFromNpm: NpmResolver },
query: LatestQuery,
opts: ResolveOptions
): Promise<LatestInfo | undefined> {
const manifestSpec = query.wantedDependency.bareSpecifier
if (query.wantedDependency.alias !== 'deno' || !manifestSpec?.startsWith('runtime:')) return undefined
const versionSpec = query.compatible ? manifestSpec.substring('runtime:'.length) : 'latest'
try {
const npmResolution = await ctx.resolveFromNpm(
{ alias: 'deno', bareSpecifier: versionSpec },
query.compatible ? opts : { ...opts, update: 'latest' }
)
if (npmResolution?.policyViolation?.code === MINIMUM_RELEASE_AGE_VIOLATION_CODE) return {}
if (!npmResolution?.manifest) return {}
return { latestManifest: { name: 'deno', version: npmResolution.manifest.version } }
} catch (err) {
if (opts.publishedBy && (err as { code?: string }).code === 'ERR_PNPM_NO_MATCHING_VERSION') {
return {}
}
throw err
}
}
function parseAssetName (name: string): PlatformAssetTarget[] | null {
const m = ASSET_REGEX.exec(name)
if (!m?.groups) return null

View File

@@ -3,6 +3,8 @@ import { PnpmError } from '@pnpm/error'
import type { FetchFromRegistry } from '@pnpm/fetching.types'
import type {
BinaryResolution,
LatestInfo,
LatestQuery,
PlatformAssetResolution,
PlatformAssetTarget,
ResolveOptions,
@@ -79,6 +81,21 @@ export async function resolveNodeRuntime (
}
}
export async function resolveLatestNodeRuntime (
ctx: { fetchFromRegistry: FetchFromRegistry, nodeDownloadMirrors?: Record<string, string> },
query: LatestQuery,
_opts: ResolveOptions
): Promise<LatestInfo | undefined> {
const manifestSpec = query.wantedDependency.bareSpecifier
if (query.wantedDependency.alias !== 'node' || !manifestSpec?.startsWith('runtime:')) return undefined
const versionSpec = query.compatible ? manifestSpec.substring('runtime:'.length) : 'latest'
const { releaseChannel, versionSpecifier } = parseNodeSpecifier(versionSpec)
const nodeMirrorBaseUrl = getNodeMirror(ctx.nodeDownloadMirrors, releaseChannel)
const version = await resolveNodeVersion(ctx.fetchFromRegistry, versionSpecifier, nodeMirrorBaseUrl)
if (!version) return {}
return { latestManifest: { name: 'node', version } }
}
async function readNodeAssets (fetch: FetchFromRegistry, nodeMirrorBaseUrl: string, version: string): Promise<PlatformAssetResolution[]> {
const assets = await readNodeAssetsFromMirror(fetch, { nodeMirrorBaseUrl, version, muslOnly: false })

View File

@@ -14,14 +14,15 @@ import {
createResolver as _createResolver,
type ResolutionVerifierFactoryOptions,
type ResolveFunction,
type ResolveLatestDispatcher,
type ResolverFactoryOptions,
} from '@pnpm/resolving.default-resolver'
import { MINIMUM_RELEASE_AGE_VIOLATION_CODE } from '@pnpm/resolving.npm-resolver'
import type { ResolutionPolicyViolation, ResolutionVerifier } from '@pnpm/resolving.resolver-base'
import type { LatestInfo, LatestQuery, ResolutionPolicyViolation, ResolutionVerifier } from '@pnpm/resolving.resolver-base'
import type { StoreIndex } from '@pnpm/store.index'
import type { RegistryConfig } from '@pnpm/types'
export type { ResolutionVerifier, ResolveFunction }
export type { LatestInfo, LatestQuery, ResolutionVerifier, ResolveFunction, ResolveLatestDispatcher }
export type ClientOptions = {
configByUri: Record<string, RegistryConfig>
@@ -77,7 +78,7 @@ export function createClient (opts: ClientOptions): Client {
}
}
export function createResolver (opts: Omit<ClientOptions, 'storeIndex'>): { resolve: ResolveFunction, clearCache: () => void } {
export function createResolver (opts: Omit<ClientOptions, 'storeIndex'>): { resolve: ResolveFunction, resolveLatest: ResolveLatestDispatcher, clearCache: () => void } {
const fetchFromRegistry = createFetchFromRegistry(opts)
const getAuthHeader = createGetAuthHeaderByURI(opts.configByUri, opts.registries?.default)

View File

@@ -5,7 +5,13 @@ import type {
ProjectManifest,
} from '@pnpm/types'
const RUNTIME_NAMES = ['node', 'deno', 'bun'] as const
export const RUNTIME_NAMES = ['node', 'deno', 'bun'] as const
export type RuntimeName = typeof RUNTIME_NAMES[number]
export function isRuntimeAlias (alias: string): alias is RuntimeName {
return (RUNTIME_NAMES as readonly string[]).includes(alias)
}
export function convertEnginesRuntimeToDependencies (
manifest: ProjectManifest,

3
pnpm-lock.yaml generated
View File

@@ -3545,9 +3545,6 @@ importers:
'@pnpm/config.parse-overrides':
specifier: workspace:*
version: link:../../../config/parse-overrides
'@pnpm/config.pick-registry-for-package':
specifier: workspace:*
version: link:../../../config/pick-registry-for-package
'@pnpm/config.version-policy':
specifier: workspace:*
version: link:../../../config/version-policy

View File

@@ -1,12 +1,12 @@
import { type BunRuntimeResolveResult, resolveBunRuntime } from '@pnpm/engine.runtime.bun-resolver'
import { type DenoRuntimeResolveResult, resolveDenoRuntime } from '@pnpm/engine.runtime.deno-resolver'
import { type NodeRuntimeResolveResult, resolveNodeRuntime } from '@pnpm/engine.runtime.node-resolver'
import { type BunRuntimeResolveResult, resolveBunRuntime, resolveLatestBunRuntime } from '@pnpm/engine.runtime.bun-resolver'
import { type DenoRuntimeResolveResult, resolveDenoRuntime, resolveLatestDenoRuntime } from '@pnpm/engine.runtime.deno-resolver'
import { type NodeRuntimeResolveResult, resolveLatestNodeRuntime, resolveNodeRuntime } from '@pnpm/engine.runtime.node-resolver'
import { PnpmError } from '@pnpm/error'
import type { FetchFromRegistry, GetAuthHeader } from '@pnpm/fetching.types'
import { checkCustomResolverCanResolve, type CustomResolver } from '@pnpm/hooks.types'
import { createGetAuthHeaderByURI } from '@pnpm/network.auth-header'
import { createGitResolver, type GitResolveResult } from '@pnpm/resolving.git-resolver'
import { type LocalResolveResult, resolveFromLocalPath, resolveFromLocalScheme } from '@pnpm/resolving.local-resolver'
import { createGitResolver, type GitResolveResult, resolveLatestFromGit } from '@pnpm/resolving.git-resolver'
import { type LocalResolveResult, resolveFromLocalPath, resolveFromLocalScheme, resolveLatestFromLocal } from '@pnpm/resolving.local-resolver'
import {
createNpmResolutionVerifier,
type CreateNpmResolutionVerifierOptions,
@@ -21,13 +21,15 @@ import {
type WorkspaceResolveResult,
} from '@pnpm/resolving.npm-resolver'
import type {
LatestInfo,
LatestQuery,
ResolutionVerifier,
ResolveFunction,
ResolveOptions,
ResolveResult,
WantedDependency,
} from '@pnpm/resolving.resolver-base'
import { resolveFromTarball, type TarballResolveResult } from '@pnpm/resolving.tarball-resolver'
import { resolveFromTarball, resolveLatestFromTarball, type TarballResolveResult } from '@pnpm/resolving.tarball-resolver'
import type { RegistryConfig } from '@pnpm/types'
export type {
@@ -90,6 +92,8 @@ async function resolveFromCustomResolvers (
return null
}
export type ResolveLatestDispatcher = (query: LatestQuery, opts: ResolveOptions) => Promise<LatestInfo | undefined>
export function createResolver (
fetchFromRegistry: FetchFromRegistry,
getAuthHeader: GetAuthHeader,
@@ -97,8 +101,16 @@ export function createResolver (
nodeDownloadMirrors?: Record<string, string>
customResolvers?: CustomResolver[]
}
): { resolve: DefaultResolver, clearCache: () => void } {
const { resolveFromNpm, resolveFromJsr, resolveFromNamedRegistry, clearCache } = createNpmResolver(fetchFromRegistry, getAuthHeader, pnpmOpts)
): { resolve: DefaultResolver, resolveLatest: ResolveLatestDispatcher, clearCache: () => void } {
const {
resolveFromNpm,
resolveFromJsr,
resolveFromNamedRegistry,
resolveLatestFromNpm,
resolveLatestFromJsr,
resolveLatestFromNamedRegistry,
clearCache,
} = createNpmResolver(fetchFromRegistry, getAuthHeader, pnpmOpts)
const resolveFromGit = createGitResolver(pnpmOpts)
const localCtx = { preserveAbsolutePaths: pnpmOpts.preserveAbsolutePaths }
const _resolveFromLocalScheme = resolveFromLocalScheme.bind(null, localCtx)
@@ -106,6 +118,9 @@ export function createResolver (
const _resolveNodeRuntime = resolveNodeRuntime.bind(null, { fetchFromRegistry, offline: pnpmOpts.offline, nodeDownloadMirrors: pnpmOpts.nodeDownloadMirrors })
const _resolveDenoRuntime = resolveDenoRuntime.bind(null, { fetchFromRegistry, offline: pnpmOpts.offline, resolveFromNpm })
const _resolveBunRuntime = resolveBunRuntime.bind(null, { fetchFromRegistry, offline: pnpmOpts.offline, resolveFromNpm })
const _resolveLatestNodeRuntime = resolveLatestNodeRuntime.bind(null, { fetchFromRegistry, nodeDownloadMirrors: pnpmOpts.nodeDownloadMirrors })
const _resolveLatestDenoRuntime = resolveLatestDenoRuntime.bind(null, { resolveFromNpm })
const _resolveLatestBunRuntime = resolveLatestBunRuntime.bind(null, { resolveFromNpm })
const _resolveFromCustomResolvers = pnpmOpts.customResolvers
? resolveFromCustomResolvers.bind(null, pnpmOpts.customResolvers)
: null
@@ -141,6 +156,18 @@ export function createResolver (
}
return resolution
},
resolveLatest: async (query, opts) => {
const info = (await resolveLatestFromNpm(query, opts)) ??
(await resolveLatestFromJsr(query, opts)) ??
(await resolveLatestFromGit(query)) ??
(await resolveLatestFromTarball(query)) ??
(await resolveLatestFromLocal(query)) ??
(await _resolveLatestNodeRuntime(query, opts)) ??
(await _resolveLatestDenoRuntime(query, opts)) ??
(await _resolveLatestBunRuntime(query, opts)) ??
(await resolveLatestFromNamedRegistry(query, opts))
return info
},
clearCache,
}
}

View File

@@ -1,6 +1,6 @@
import { PnpmError } from '@pnpm/error'
import type { DispatcherOptions } from '@pnpm/network.fetch'
import type { GitResolution, PkgResolutionId, ResolveOptions, ResolveResult, TarballResolution } from '@pnpm/resolving.resolver-base'
import type { GitResolution, LatestInfo, LatestQuery, PkgResolutionId, ResolveOptions, ResolveResult, TarballResolution } from '@pnpm/resolving.resolver-base'
import { gracefulGit as git } from 'graceful-git'
import semver from 'semver'
@@ -101,6 +101,18 @@ export function createGitResolver (
}
}
// Git deps have no concept of "latest" — we'd need to query the host's tag list
// to know about newer commits, which isn't a uniform thing across protocols.
// Claim the dep so the dispatcher stops; the caller still surfaces a
// ref-mismatch report if the lockfile shifted to a different commit.
export async function resolveLatestFromGit (query: LatestQuery): Promise<LatestInfo | undefined> {
const bareSpecifier = query.wantedDependency.bareSpecifier
if (!bareSpecifier) return undefined
const parsedSpecFunc = parseBareSpecifier(bareSpecifier, {})
if (parsedSpecFunc == null) return undefined
return {}
}
function resolveVTags (vTags: string[], range: string): string | null {
return semver.maxSatisfying(vTags, range, true)
}

View File

@@ -4,7 +4,7 @@ import path from 'node:path'
import { getTarballIntegrity } from '@pnpm/crypto.hash'
import { PnpmError } from '@pnpm/error'
import { logger } from '@pnpm/logger'
import type { DirectoryResolution, Resolution, ResolveResult, TarballResolution } from '@pnpm/resolving.resolver-base'
import type { DirectoryResolution, LatestInfo, LatestQuery, Resolution, ResolveResult, TarballResolution } from '@pnpm/resolving.resolver-base'
import type { DependencyManifest, PkgResolutionId } from '@pnpm/types'
import { readProjectManifestOnly } from '@pnpm/workspace.project-manifest-reader'
@@ -64,6 +64,18 @@ export async function resolveFromLocalPath (
return resolveSpec(spec, opts)
}
// link:/file:/workspace: dependencies don't have a "latest" — claim them so
// the dispatcher stops here. Returning undefined would let downstream
// resolvers try; in particular, a user-configured named-registry alias
// called `link`, `file`, or `workspace` could otherwise hijack these.
export async function resolveLatestFromLocal (query: LatestQuery): Promise<LatestInfo | undefined> {
const spec = query.wantedDependency.bareSpecifier
if (spec?.startsWith('link:') || spec?.startsWith('file:') || spec?.startsWith('workspace:')) {
return {}
}
return undefined
}
async function resolveSpec (
spec: LocalPackageSpec | null,
opts: LocalResolverOptions

View File

@@ -10,10 +10,13 @@ import type {
import type { PackageInRegistry, PackageMeta } from '@pnpm/resolving.registry.types'
import type {
DirectoryResolution,
LatestInfo,
LatestQuery,
PkgResolutionId,
PreferredVersions,
Resolution,
ResolutionPolicyViolation,
ResolveOptions,
ResolveResult,
TarballResolution,
WantedDependency,
@@ -181,11 +184,24 @@ export type NpmResolver = (
opts: ResolveFromNpmOptions
) => Promise<NpmResolveResult | JsrResolveResult | NamedRegistryResolveResult | WorkspaceResolveResult | null>
export type ResolveLatestFromNpmStyle = (
query: LatestQuery,
opts: ResolveOptions
) => Promise<LatestInfo | undefined>
export function createNpmResolver (
fetchFromRegistry: FetchFromRegistry,
getAuthHeader: GetAuthHeader,
opts: ResolverFactoryOptions
): { resolveFromNpm: NpmResolver, resolveFromJsr: NpmResolver, resolveFromNamedRegistry: NpmResolver, clearCache: () => void } {
): {
resolveFromNpm: NpmResolver
resolveFromJsr: NpmResolver
resolveFromNamedRegistry: NpmResolver
resolveLatestFromNpm: ResolveLatestFromNpmStyle
resolveLatestFromJsr: ResolveLatestFromNpmStyle
resolveLatestFromNamedRegistry: ResolveLatestFromNpmStyle
clearCache: () => void
} {
if (typeof opts.cacheDir !== 'string') {
throw new TypeError('`opts.cacheDir` is required and needs to be a string')
}
@@ -250,10 +266,19 @@ export function createNpmResolver (
saveWorkspaceProtocol: opts.saveWorkspaceProtocol,
peekManifestFromStore,
}
const boundResolveFromNpm = resolveNpm.bind(null, ctx)
const boundResolveFromJsr = resolveJsr.bind(null, ctx)
const boundResolveFromNamedRegistry = resolveFromNamedRegistry.bind(null, ctx)
const defaultRegistry = opts.registries.default
return {
resolveFromNpm: resolveNpm.bind(null, ctx),
resolveFromJsr: resolveJsr.bind(null, ctx),
resolveFromNamedRegistry: resolveFromNamedRegistry.bind(null, ctx),
resolveFromNpm: boundResolveFromNpm,
resolveFromJsr: boundResolveFromJsr,
resolveFromNamedRegistry: boundResolveFromNamedRegistry,
resolveLatestFromNpm: createResolveLatest(boundResolveFromNpm,
(query) => isNpmSpec(query, defaultRegistry)),
resolveLatestFromJsr: createResolveLatest(boundResolveFromJsr, isJsrSpec),
resolveLatestFromNamedRegistry: createResolveLatest(boundResolveFromNamedRegistry,
(query) => isNamedRegistrySpec(query, ctx.namedRegistryNames)),
clearCache: () => {
if ('clear' in metaCache && typeof metaCache.clear === 'function') {
metaCache.clear()
@@ -263,6 +288,70 @@ export function createNpmResolver (
}
}
function isNpmSpec (query: LatestQuery, defaultRegistry: string): boolean {
const { alias, bareSpecifier } = query.wantedDependency
if (!bareSpecifier) return alias != null
return parseBareSpecifier(bareSpecifier, alias, 'latest', defaultRegistry) != null
}
function isJsrSpec (query: LatestQuery): boolean {
if (!query.wantedDependency.bareSpecifier?.startsWith('jsr:')) return false
return parseJsrSpecifierToRegistryPackageSpec(
query.wantedDependency.bareSpecifier,
query.wantedDependency.alias,
'latest'
) != null
}
function isNamedRegistrySpec (
query: LatestQuery,
knownRegistryNames: ReadonlySet<string>
): boolean {
if (!query.wantedDependency.bareSpecifier) return false
try {
return parseNamedRegistrySpecifierToRegistryPackageSpec(
query.wantedDependency.bareSpecifier,
knownRegistryNames,
query.wantedDependency.alias,
'latest'
) != null
} catch {
return false
}
}
function createResolveLatest (
resolve: NpmResolver,
matches: (query: LatestQuery) => boolean
) {
return async (query: LatestQuery, opts: ResolveOptions): Promise<LatestInfo | undefined> => {
if (!matches(query)) return undefined
// Always pass the manifest's bareSpecifier so protocol-prefixed specs
// (`jsr:@scope/pkg@^1.0.0`, `gh:owner/repo@^1.0.0`) still match their
// resolver. In --compatible mode that range drives the pick; otherwise
// `update: 'latest'` tells the resolver to ignore the range and take
// the absolute newest.
const bareSpecifier = query.wantedDependency.bareSpecifier ?? 'latest'
const resolveOpts = query.compatible ? opts : { ...opts, update: 'latest' as const }
try {
const result = await resolve(
{ alias: query.wantedDependency.alias, bareSpecifier },
resolveOpts
)
// Policy-blocked: handled but no latest to surface.
if (result?.policyViolation?.code === MINIMUM_RELEASE_AGE_VIOLATION_CODE) {
return {}
}
return { latestManifest: result?.manifest }
} catch (err) {
if (opts.publishedBy && (err as { code?: string }).code === 'ERR_PNPM_NO_MATCHING_VERSION') {
return {}
}
throw err
}
}
}
export interface ResolveFromNpmContext {
pickPackage: (spec: RegistryPackageSpec, opts: PickPackageOptions) => ReturnType<typeof pickPackage>
getAuthHeaderValueByURI: (registry: string) => string | undefined

View File

@@ -1,5 +1,6 @@
import type {
DependencyManifest,
PackageManifest,
PackageVersionPolicy,
PinnedVersion,
PkgResolutionId,
@@ -312,3 +313,34 @@ export type WantedDependency = {
})
export type ResolveFunction = (wantedDependency: WantedDependency & { optional?: boolean }, opts: ResolveOptions) => Promise<ResolveResult>
/**
* Input to a resolver's `resolveLatest` function. The resolver decides
* whether it owns this dep purely from `wantedDependency` (its alias and
* manifest specifier) — the lockfile-resolved ref is the caller's
* concern, not the resolver's.
*/
export interface LatestQuery {
wantedDependency: WantedDependency
compatible?: boolean
}
/**
* Result of a resolver's `resolveLatest` call.
*
* - `undefined` means "this resolver does not handle this dep — try
* the next one".
* - An object (even without a `latestManifest`) means "I claim this
* dep, but I can't tell you what's latest" (e.g. policy blocked,
* network unavailable, no concept of latest for this protocol).
* The caller still surfaces a ref-mismatch report if the lockfile
* shifted.
*/
export interface LatestInfo {
latestManifest?: PackageManifest
}
export type ResolveLatestFunction = (
query: LatestQuery,
opts: ResolveOptions
) => Promise<LatestInfo | undefined>

View File

@@ -1,5 +1,5 @@
import type { FetchFromRegistry } from '@pnpm/fetching.types'
import type { PkgResolutionId, ResolveResult, TarballResolution } from '@pnpm/resolving.resolver-base'
import type { LatestInfo, LatestQuery, PkgResolutionId, ResolveResult, TarballResolution } from '@pnpm/resolving.resolver-base'
export interface TarballResolveResult extends ResolveResult {
normalizedBareSpecifier: string
@@ -36,3 +36,12 @@ export async function resolveFromTarball (
resolvedVia: 'url',
}
}
// URL tarballs lock to the exact URL — no concept of "latest". Claim the dep
// so the dispatcher stops; the caller still surfaces a ref-mismatch report
// if the lockfile points at a different URL than before.
export async function resolveLatestFromTarball (query: LatestQuery): Promise<LatestInfo | undefined> {
const bareSpecifier = query.wantedDependency.bareSpecifier
if (!bareSpecifier?.startsWith('http:') && !bareSpecifier?.startsWith('https:')) return undefined
return {}
}