From 2e07c4f6ffe5bc339f222fb7d96c345b734a3de2 Mon Sep 17 00:00:00 2001 From: Ryo Matsukawa <76232929+ryo-manba@users.noreply.github.com> Date: Wed, 1 Oct 2025 01:30:41 +0900 Subject: [PATCH] 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 --- .changeset/dry-impalas-mate.md | 7 + .../outdated/src/createManifestGetter.ts | 57 ++++- reviewing/outdated/src/outdated.ts | 2 + .../outdated/src/outdatedDepsOfProjects.ts | 8 +- reviewing/outdated/test/getManifest.spec.ts | 107 +++++++- reviewing/outdated/test/outdated.spec.ts | 233 ++++++++++++++++++ .../plugin-commands-outdated/src/outdated.ts | 4 + .../plugin-commands-outdated/src/recursive.ts | 2 + 8 files changed, 407 insertions(+), 13 deletions(-) create mode 100644 .changeset/dry-impalas-mate.md diff --git a/.changeset/dry-impalas-mate.md b/.changeset/dry-impalas-mate.md new file mode 100644 index 0000000000..a32dd9b7e9 --- /dev/null +++ b/.changeset/dry-impalas-mate.md @@ -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). diff --git a/reviewing/outdated/src/createManifestGetter.ts b/reviewing/outdated/src/createManifestGetter.ts index 3e7cc59f42..1e010c061d 100644 --- a/reviewing/outdated/src/createManifestGetter.ts +++ b/reviewing/outdated/src/createManifestGetter.ts @@ -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 @@ -18,20 +21,54 @@ export type ManifestGetterOptions = Omit export function createManifestGetter ( opts: ManifestGetterOptions ): (packageName: string, bareSpecifier: string) => Promise { - 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 { - 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 + } } diff --git a/reviewing/outdated/src/outdated.ts b/reviewing/outdated/src/outdated.ts index df66733396..89d44d0542 100644 --- a/reviewing/outdated/src/outdated.ts +++ b/reviewing/outdated/src/outdated.ts @@ -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 diff --git a/reviewing/outdated/src/outdatedDepsOfProjects.ts b/reviewing/outdated/src/outdatedDepsOfProjects.ts index e84f6df132..37e7ca69eb 100644 --- a/reviewing/outdated/src/outdatedDepsOfProjects.ts +++ b/reviewing/outdated/src/outdatedDepsOfProjects.ts @@ -22,6 +22,8 @@ export async function outdatedDepsOfProjects ( compatible?: boolean ignoreDependencies?: string[] include: IncludedDependencies + minimumReleaseAge?: number + minimumReleaseAgeExclude?: string[] } & Partial> ): Promise { 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 => { 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, diff --git a/reviewing/outdated/test/getManifest.spec.ts b/reviewing/outdated/test/getManifest.spec.ts index 77aa708ee4..82b58e8f17 100644 --- a/reviewing/outdated/test/getManifest.spec.ts +++ b/reviewing/outdated/test/getManifest.spec.ts @@ -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) +}) diff --git a/reviewing/outdated/test/outdated.spec.ts b/reviewing/outdated/test/outdated.spec.ts index d764677a40..5179a020d6 100644 --- a/reviewing/outdated/test/outdated.spec.ts +++ b/reviewing/outdated/test/outdated.spec.ts @@ -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: { diff --git a/reviewing/plugin-commands-outdated/src/outdated.ts b/reviewing/plugin-commands-outdated/src/outdated.ts index ada821e354..0ab8bffacd 100644 --- a/reviewing/plugin-commands-outdated/src/outdated.ts +++ b/reviewing/plugin-commands-outdated/src/outdated.ts @@ -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, diff --git a/reviewing/plugin-commands-outdated/src/recursive.ts b/reviewing/plugin-commands-outdated/src/recursive.ts index 9731cade19..9b503bddfe 100644 --- a/reviewing/plugin-commands-outdated/src/recursive.ts +++ b/reviewing/plugin-commands-outdated/src/recursive.ts @@ -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,