feat: respect minimumReleaseAge in outdated command (#10030)

close #10009

* feat: respect minimumReleaseAge in outdated command

* chore: add changeset

* fix: remove unnecessary 'latest' to '*' conversion in outdated command

* refactor: move publishedBy and matcher creation outside getManifest

* refactor: outdated

* docs: update changeset

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Ryo Matsukawa
2025-10-01 01:30:41 +09:00
committed by GitHub
parent 2bfbdfc55c
commit 2e07c4f6ff
8 changed files with 407 additions and 13 deletions

View File

@@ -0,0 +1,7 @@
---
"@pnpm/plugin-commands-outdated": patch
"@pnpm/outdated": patch
"pnpm": patch
---
Outdated command respects `minimumReleaseAge` configuration [#10030](https://github.com/pnpm/pnpm/pull/10030).

View File

@@ -3,12 +3,15 @@ import {
createResolver,
type ResolveFunction,
} from '@pnpm/client'
import { createMatcher } from '@pnpm/matcher'
import { type DependencyManifest } from '@pnpm/types'
interface GetManifestOpts {
dir: string
lockfileDir: string
rawConfig: object
minimumReleaseAge?: number
minimumReleaseAgeExclude?: string[]
}
export type ManifestGetterOptions = Omit<ClientOptions, 'authConfig'>
@@ -18,20 +21,54 @@ export type ManifestGetterOptions = Omit<ClientOptions, 'authConfig'>
export function createManifestGetter (
opts: ManifestGetterOptions
): (packageName: string, bareSpecifier: string) => Promise<DependencyManifest | null> {
const { resolve } = createResolver({ ...opts, authConfig: opts.rawConfig })
return getManifest.bind(null, resolve, opts)
const { resolve } = createResolver({
...opts,
authConfig: opts.rawConfig,
filterMetadata: Boolean(opts.minimumReleaseAge),
strictPublishedByCheck: Boolean(opts.minimumReleaseAge),
})
const publishedBy = opts.minimumReleaseAge
? new Date(Date.now() - opts.minimumReleaseAge * 60 * 1000)
: undefined
const isExcludedMatcher = opts.minimumReleaseAgeExclude
? createMatcher(opts.minimumReleaseAgeExclude)
: undefined
return getManifest.bind(null, {
...opts,
resolve,
publishedBy,
isExcludedMatcher,
})
}
export async function getManifest (
resolve: ResolveFunction,
opts: GetManifestOpts,
opts: GetManifestOpts & {
resolve: ResolveFunction
publishedBy?: Date
isExcludedMatcher?: ((packageName: string) => boolean)
},
packageName: string,
bareSpecifier: string
): Promise<DependencyManifest | null> {
const resolution = await resolve({ alias: packageName, bareSpecifier }, {
lockfileDir: opts.lockfileDir,
preferredVersions: {},
projectDir: opts.dir,
})
return resolution?.manifest ?? null
const isExcluded = opts.isExcludedMatcher?.(packageName)
const effectivePublishedBy = isExcluded ? undefined : opts.publishedBy
try {
const resolution = await opts.resolve({ alias: packageName, bareSpecifier }, {
lockfileDir: opts.lockfileDir,
preferredVersions: {},
projectDir: opts.dir,
publishedBy: effectivePublishedBy,
})
return resolution?.manifest ?? null
} catch (err) {
if ((err as { code?: string }).code === 'ERR_PNPM_NO_MATCHING_VERSION' && effectivePublishedBy) {
// No versions found that meet the minimumReleaseAge requirement
return null
}
throw err
}
}

View File

@@ -55,6 +55,8 @@ export async function outdated (
lockfileDir: string
manifest: ProjectManifest
match?: (dependencyName: string) => boolean
minimumReleaseAge?: number
minimumReleaseAgeExclude?: string[]
prefix: string
registries: Registries
wantedLockfile: LockfileObject | null

View File

@@ -22,6 +22,8 @@ export async function outdatedDepsOfProjects (
compatible?: boolean
ignoreDependencies?: string[]
include: IncludedDependencies
minimumReleaseAge?: number
minimumReleaseAgeExclude?: string[]
} & Partial<Pick<ManifestGetterOptions, 'fullMetadata' | 'lockfileDir'>>
): Promise<OutdatedPackage[][]> {
if (!opts.lockfileDir) {
@@ -37,8 +39,10 @@ export async function outdatedDepsOfProjects (
const wantedLockfile = await readWantedLockfile(lockfileDir, { ignoreIncompatible: false }) ?? currentLockfile
const getLatestManifest = createManifestGetter({
...opts,
fullMetadata: opts.fullMetadata === true,
fullMetadata: opts.fullMetadata === true || Boolean(opts.minimumReleaseAge),
lockfileDir,
minimumReleaseAge: opts.minimumReleaseAge,
minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude,
})
return Promise.all(pkgs.map(async ({ rootDir, manifest }): Promise<OutdatedPackage[]> => {
const match = (args.length > 0) && createMatcher(args) || undefined
@@ -52,6 +56,8 @@ export async function outdatedDepsOfProjects (
lockfileDir,
manifest,
match,
minimumReleaseAge: opts.minimumReleaseAge,
minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude,
prefix: rootDir,
registries: opts.registries,
wantedLockfile,

View File

@@ -22,7 +22,7 @@ test('getManifest()', async () => {
}
}
expect(await getManifest(resolve, opts, 'foo', 'latest')).toStrictEqual({
expect(await getManifest({ ...opts, resolve }, 'foo', 'latest')).toStrictEqual({
name: 'foo',
version: '1.0.0',
})
@@ -40,8 +40,111 @@ test('getManifest()', async () => {
}
}
expect(await getManifest(resolve2, opts, '@scope/foo', 'latest')).toStrictEqual({
expect(await getManifest({ ...opts, resolve: resolve2 }, '@scope/foo', 'latest')).toStrictEqual({
name: 'foo',
version: '2.0.0',
})
})
test('getManifest() with minimumReleaseAge filters latest when too new', async () => {
const opts = {
dir: '',
lockfileDir: '',
rawConfig: {},
minimumReleaseAge: 10080,
}
const publishedBy = new Date(Date.now() - 10080 * 60 * 1000)
const resolve: ResolveFunction = jest.fn(async function (wantedPackage, resolveOpts) {
expect(wantedPackage.bareSpecifier).toBe('latest')
expect(resolveOpts.publishedBy).toBeInstanceOf(Date)
// Simulate latest version being too new
const error = new Error('No matching version found') as Error & { code?: string }
error.code = 'ERR_PNPM_NO_MATCHING_VERSION'
throw error
})
const result = await getManifest({ ...opts, resolve, publishedBy }, 'foo', 'latest')
expect(result).toBeNull()
expect(resolve).toHaveBeenCalledTimes(1)
})
test('getManifest() does not convert non-latest specifiers', async () => {
const opts = {
dir: '',
lockfileDir: '',
rawConfig: {},
}
const resolve: ResolveFunction = jest.fn(async function (wantedPackage, resolveOpts) {
expect(wantedPackage.bareSpecifier).toBe('^1.0.0')
return {
id: 'foo/1.5.0' as PkgResolutionId,
latest: '2.0.0',
manifest: {
name: 'foo',
version: '1.5.0',
},
resolution: {} as TarballResolution,
resolvedVia: 'npm-registry',
}
})
await getManifest({ ...opts, resolve }, 'foo', '^1.0.0')
expect(resolve).toHaveBeenCalledTimes(1)
})
test('getManifest() handles NO_MATCHING_VERSION error gracefully', async () => {
const opts = {
dir: '',
lockfileDir: '',
rawConfig: {},
}
const publishedBy = new Date(Date.now() - 10080 * 60 * 1000)
const resolve: ResolveFunction = jest.fn(async function () {
const error = new Error('No matching version found') as Error & { code?: string }
error.code = 'ERR_PNPM_NO_MATCHING_VERSION'
throw error
})
const result = await getManifest({ ...opts, resolve, publishedBy }, 'foo', 'latest')
// Should return null when no version matches minimumReleaseAge
expect(result).toBeNull()
})
test('getManifest() with minimumReleaseAgeExclude', async () => {
const opts = {
dir: '',
lockfileDir: '',
rawConfig: {},
}
const publishedBy = new Date(Date.now() - 10080 * 60 * 1000)
const isExcludedMatcher = (packageName: string) => packageName === 'excluded-package'
const resolve: ResolveFunction = jest.fn(async function (wantedPackage, resolveOpts) {
// Excluded package should not have publishedBy set
expect(resolveOpts.publishedBy).toBeUndefined()
return {
id: 'excluded-package/2.0.0' as PkgResolutionId,
latest: '2.0.0',
manifest: {
name: 'excluded-package',
version: '2.0.0',
},
resolution: {} as TarballResolution,
resolvedVia: 'npm-registry',
}
})
await getManifest({ ...opts, resolve, isExcludedMatcher, publishedBy }, 'excluded-package', 'latest')
expect(resolve).toHaveBeenCalledTimes(1)
})

View File

@@ -240,6 +240,239 @@ test('outdated() should return deprecated package even if its current version is
])
})
test('outdated() with minimumReleaseAge', async () => {
const getLatestManifestForMinimumAge = async (packageName: string) => {
// Simulate packages where 'is-negative' 2.1.0 is filtered out due to minimumReleaseAge
// and returns 2.0.0 instead
return ({
'is-negative': {
name: 'is-negative',
version: '2.0.0', // older version within the age limit
},
'is-positive': {
name: 'is-positive',
version: '3.1.0',
},
})[packageName] ?? null
}
const outdatedPkgs = await outdated({
currentLockfile: {
importers: {
['.' as ProjectId]: {
devDependencies: {
'is-negative': '1.0.0',
'is-positive': '1.0.0',
},
specifiers: {
'is-negative': '^2.1.0',
'is-positive': '^1.0.0',
},
},
},
lockfileVersion: LOCKFILE_VERSION,
packages: {
['is-negative@1.0.0' as DepPath]: {
resolution: {
integrity: 'sha512-xxx',
},
},
['is-positive@1.0.0' as DepPath]: {
resolution: {
integrity: 'sha512-yyy',
},
},
},
},
getLatestManifest: getLatestManifestForMinimumAge,
lockfileDir: 'project',
manifest: {
name: 'with-min-age',
version: '1.0.0',
devDependencies: {
'is-negative': '^2.1.0',
'is-positive': '^1.0.0',
},
},
prefix: 'project',
wantedLockfile: {
importers: {
['.' as ProjectId]: {
devDependencies: {
'is-negative': '2.1.0',
'is-positive': '3.1.0',
},
specifiers: {
'is-negative': '^2.1.0',
'is-positive': '^1.0.0',
},
},
},
lockfileVersion: LOCKFILE_VERSION,
packages: {
['is-negative@2.1.0' as DepPath]: {
resolution: {
integrity: 'sha512-xxx',
},
},
['is-positive@3.1.0' as DepPath]: {
resolution: {
integrity: 'sha512-zzz',
},
},
},
},
registries: {
default: 'https://registry.npmjs.org/',
},
minimumReleaseAge: 10080,
})
expect(outdatedPkgs).toStrictEqual([
{
alias: 'is-negative',
belongsTo: 'devDependencies',
current: '1.0.0',
latestManifest: {
name: 'is-negative',
version: '2.0.0', // older version returned due to minimumReleaseAge
},
packageName: 'is-negative',
wanted: '2.1.0',
workspace: 'with-min-age',
},
{
alias: 'is-positive',
belongsTo: 'devDependencies',
current: '1.0.0',
latestManifest: {
name: 'is-positive',
version: '3.1.0',
},
packageName: 'is-positive',
wanted: '3.1.0',
workspace: 'with-min-age',
},
])
})
test('outdated() with minimumReleaseAgeExclude', async () => {
const getLatestManifestWithExclude = async (packageName: string) => {
// Simulate that 'is-negative' is excluded from minimumReleaseAge
// so it returns the real latest version
return ({
'is-negative': {
name: 'is-negative',
version: '2.1.0', // latest version (excluded from age filter)
},
'is-positive': {
name: 'is-positive',
version: '3.0.0', // older version (age filter applied)
},
})[packageName] ?? null
}
const outdatedPkgs = await outdated({
currentLockfile: {
importers: {
['.' as ProjectId]: {
devDependencies: {
'is-negative': '1.0.0',
'is-positive': '1.0.0',
},
specifiers: {
'is-negative': '^2.1.0',
'is-positive': '^1.0.0',
},
},
},
lockfileVersion: LOCKFILE_VERSION,
packages: {
['is-negative@1.0.0' as DepPath]: {
resolution: {
integrity: 'sha512-xxx',
},
},
['is-positive@1.0.0' as DepPath]: {
resolution: {
integrity: 'sha512-yyy',
},
},
},
},
getLatestManifest: getLatestManifestWithExclude,
lockfileDir: 'project',
manifest: {
name: 'with-exclude',
version: '1.0.0',
devDependencies: {
'is-negative': '^2.1.0',
'is-positive': '^1.0.0',
},
},
prefix: 'project',
wantedLockfile: {
importers: {
['.' as ProjectId]: {
devDependencies: {
'is-negative': '2.1.0',
'is-positive': '3.1.0',
},
specifiers: {
'is-negative': '^2.1.0',
'is-positive': '^1.0.0',
},
},
},
lockfileVersion: LOCKFILE_VERSION,
packages: {
['is-negative@2.1.0' as DepPath]: {
resolution: {
integrity: 'sha512-xxx',
},
},
['is-positive@3.1.0' as DepPath]: {
resolution: {
integrity: 'sha512-zzz',
},
},
},
},
registries: {
default: 'https://registry.npmjs.org/',
},
minimumReleaseAge: 10080,
minimumReleaseAgeExclude: ['is-negative'],
})
expect(outdatedPkgs).toStrictEqual([
{
alias: 'is-negative',
belongsTo: 'devDependencies',
current: '1.0.0',
latestManifest: {
name: 'is-negative',
version: '2.1.0', // latest version (excluded from age filter)
},
packageName: 'is-negative',
wanted: '2.1.0',
workspace: 'with-exclude',
},
{
alias: 'is-positive',
belongsTo: 'devDependencies',
current: '1.0.0',
latestManifest: {
name: 'is-positive',
version: '3.0.0', // older version (age filter applied)
},
packageName: 'is-positive',
wanted: '3.1.0',
workspace: 'with-exclude',
},
])
})
test('using a matcher', async () => {
const outdatedPkgs = await outdated({
currentLockfile: {

View File

@@ -156,6 +156,8 @@ export type OutdatedCommandOptions = {
| 'key'
| 'localAddress'
| 'lockfileDir'
| 'minimumReleaseAge'
| 'minimumReleaseAgeExclude'
| 'networkConcurrency'
| 'noProxy'
| 'offline'
@@ -195,6 +197,8 @@ export async function handler (
fullMetadata: opts.long,
ignoreDependencies: opts.updateConfig?.ignoreDependencies,
include,
minimumReleaseAge: opts.minimumReleaseAge,
minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude,
retry: {
factor: opts.fetchRetryFactor,
maxTimeout: opts.fetchRetryMaxtimeout,

View File

@@ -57,6 +57,8 @@ export async function outdatedRecursive (
...opts,
fullMetadata: opts.long,
ignoreDependencies: opts.updateConfig?.ignoreDependencies,
minimumReleaseAge: opts.minimumReleaseAge,
minimumReleaseAgeExclude: opts.minimumReleaseAgeExclude,
retry: {
factor: opts.fetchRetryFactor,
maxTimeout: opts.fetchRetryMaxtimeout,