From 9dcf0d3acb45e04f15606610d40d641f106c3d71 Mon Sep 17 00:00:00 2001 From: Sanjays2402 <51058514+Sanjays2402@users.noreply.github.com> Date: Fri, 3 Jul 2026 06:47:35 -0700 Subject: [PATCH] fix(arborist): apply min-release-age-exclude on reify fallback When omit-lockfile-registry-resolved=true, the lockfile has no `resolved` URL so #extractOrLink falls back to a name@version registry spec. Pacote resolves that via pickManifest which honors `before`, so a package pinned in the lockfile can throw ETARGET even when its name is in min-release-age-exclude. Mirror #releaseAgeBefore in build-ideal-tree and drop `before` for excluded packages on this fallback path. Fixes: https://github.com/npm/cli/issues/9715 --- workspaces/arborist/lib/arborist/reify.js | 15 +++++ workspaces/arborist/test/arborist/reify.js | 71 ++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/workspaces/arborist/lib/arborist/reify.js b/workspaces/arborist/lib/arborist/reify.js index b099d4d72c6a4..59764e23263cc 100644 --- a/workspaces/arborist/lib/arborist/reify.js +++ b/workspaces/arborist/lib/arborist/reify.js @@ -26,6 +26,7 @@ const optionalSet = require('../optional-set.js') const relpath = require('../relpath.js') const { applyPatchToDir, patchIntegrity } = require('../patch.js') const { readFile } = require('node:fs/promises') +const { isReleaseAgeExcluded } = require('../release-age-exclude.js') const retirePath = require('../retire-path.js') const treeCheck = require('../tree-check.js') const Shrinkwrap = require('../shrinkwrap.js') @@ -705,6 +706,15 @@ module.exports = cls => class Reifier extends cls { // Do the best with what we have, or else remove it from the tree // entirely, since we can't possibly reify it. let res = null + // Fallback path: rebuild the pacote spec from the lockfile identity + // (e.g. omit-lockfile-registry-resolved wrote no `resolved` URL, so we + // must re-resolve via the registry). This spec is a name@version, so + // pacote will call pickManifest which honors `before` and can throw + // ETARGET even for the pinned version if it was published after the + // release-age cutoff. Mirror #releaseAgeBefore in build-ideal-tree and + // drop `before` when the package matches `min-release-age-exclude` so + // the exemption applies to the reify fallback too (npm/cli#9715). + let releaseAgeExcluded = false if (node.resolved) { const registryResolved = this.#registryResolved(node.resolved) if (registryResolved) { @@ -712,6 +722,8 @@ module.exports = cls => class Reifier extends cls { } } else if (node.package.name && node.version) { res = `${node.package.name}@${node.version}` + releaseAgeExcluded = !!this.options.before && + isReleaseAgeExcluded(node.package.name, this.options.minReleaseAgeExclude) } // no idea what this thing is. remove it from the tree. @@ -750,6 +762,9 @@ module.exports = cls => class Reifier extends cls { // pacote's npa re-parses our `name@URL` spec as type=remote, so allowRemote would mis-fire on registry tarballs. // Override only when we can prove the URL is registry-mediated; see #isRegistryResolvedTarball. ...(this.#isRegistryResolvedTarball(node) ? { allowRemote: 'all' } : {}), + // Drop `before` for packages exempted by min-release-age-exclude when + // we fall back to a name@version registry spec (npm/cli#9715). + ...(releaseAgeExcluded ? { before: null } : {}), }) // store nodes don't use Node class so node.package doesn't get updated if (node.isInStore) { diff --git a/workspaces/arborist/test/arborist/reify.js b/workspaces/arborist/test/arborist/reify.js index d453c79baa325..e83249e01fee7 100644 --- a/workspaces/arborist/test/arborist/reify.js +++ b/workspaces/arborist/test/arborist/reify.js @@ -167,6 +167,77 @@ t.test('weirdly broken lockfile without resolved value', async t => { await t.resolveMatchSnapshot(printReified(fixture(t, 'dep-missing-resolved'))) }) +t.test('min-release-age-exclude bypasses before filter when reifying from a lockfile with missing resolved (npm/cli#9715)', async t => { + // When `omit-lockfile-registry-resolved=true` strips the `resolved` URL, + // `npm ci` and subsequent `npm i` runs fall back to a `name@version` spec + // and pacote re-fetches the packument. If `before` (min-release-age) is set, + // pickManifest throws ETARGET even for the version pinned in the lockfile. + // `min-release-age-exclude` must exempt the package on the reify fallback too, + // mirroring what build-ideal-tree's #releaseAgeBefore already does. + + // abbrev 1.1.1 was published 2017-09-28; a `before` in mid-2017 filters it out. + const pkg = 'abbrev' + const before = new Date('2017-06-01T00:00:00Z') + const integrity = 'sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==' + + // A lockfile shaped the way omit-lockfile-registry-resolved writes it: the + // registry dep has integrity + version but no `resolved` URL. + const mkLockfile = () => JSON.stringify({ + name: 'repro', + version: '1.0.0', + lockfileVersion: 3, + requires: true, + packages: { + '': { + name: 'repro', + version: '1.0.0', + dependencies: { [pkg]: '1.1.1' }, + }, + [`node_modules/${pkg}`]: { version: '1.1.1', integrity }, + }, + }) + + const mkPath = () => t.testdir({ + 'package.json': JSON.stringify({ + name: 'repro', + version: '1.0.0', + dependencies: { [pkg]: '1.1.1' }, + }), + 'package-lock.json': mkLockfile(), + }) + + await t.test('baseline: before window without exclude throws ETARGET', async t => { + const path = mkPath() + createRegistry(t, true) + await t.rejects(reify(path, { before }), { code: 'ETARGET' }, + 'reify fallback re-resolves via packument and honors before') + }) + + await t.test('exact name in min-release-age-exclude installs pinned version', async t => { + const path = mkPath() + createRegistry(t, true) + const tree = await reify(path, { before, minReleaseAgeExclude: [pkg] }) + t.equal(tree.children.get(pkg).version, '1.1.1', + 'reified pinned version via the fallback spec with exclude bypass') + }) + + await t.test('glob min-release-age-exclude installs pinned version', async t => { + const path = mkPath() + createRegistry(t, true) + const tree = await reify(path, { before, minReleaseAgeExclude: ['abb*'] }) + t.equal(tree.children.get(pkg).version, '1.1.1', + 'glob pattern in exclude also bypasses the before filter') + }) + + await t.test('non-matching min-release-age-exclude leaves the filter in place', async t => { + const path = mkPath() + createRegistry(t, true) + await t.rejects(reify(path, { before, minReleaseAgeExclude: ['not-abbrev'] }), + { code: 'ETARGET' }, + 'exemption is scoped to matched names only') + }) +}) + t.test('testing-peer-deps package', async t => { createRegistry(t, true) await t.resolveMatchSnapshot(printReified(fixture(t, 'testing-peer-deps')))