From 93b2cb40d73b9253e603c105b4c06537b5217e95 Mon Sep 17 00:00:00 2001 From: Sanjays2402 <51058514+Sanjays2402@users.noreply.github.com> Date: Wed, 15 Jul 2026 02:24:12 -0700 Subject: [PATCH 1/2] fix(libnpmexec): honor min-release-age-exclude in npx npx calls pacote.manifest directly with flatOptions.before set by min-release-age, but pacote has no notion of min-release-age-exclude so npx would fail with ETARGET even for packages that should be exempt. arborist already does the equivalent per-spec drop inside npm install; do the same in libnpmexec's getManifest by clearing before when the resolved spec name matches an exclude pattern. Fixes: #9765 --- package-lock.json | 13 +++ workspaces/libnpmexec/lib/index.js | 10 ++- .../libnpmexec/lib/release-age-exclude.js | 56 +++++++++++++ workspaces/libnpmexec/package.json | 1 + .../libnpmexec/test/release-age-exclude.js | 80 +++++++++++++++++++ 5 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 workspaces/libnpmexec/lib/release-age-exclude.js create mode 100644 workspaces/libnpmexec/test/release-age-exclude.js diff --git a/package-lock.json b/package-lock.json index 0bcc1c791d892..e9aea3bcde4b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6203,6 +6203,18 @@ } } }, + "node_modules/git-raw-commits/node_modules/conventional-commits-filter": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz", + "integrity": "sha512-tQMagCOC59EVgNZcC5zl7XqO30Wki9i9J3acbUvkaosCT6JX3EeFwJD7Qqp4MCikRnzS18WXV3BLIQ66ytu6+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/github-slugger": { "version": "2.0.0", "dev": true, @@ -15077,6 +15089,7 @@ "@npmcli/package-json": "^8.0.0", "@npmcli/run-script": "^11.0.0", "ci-info": "^4.0.0", + "minimatch": "^10.0.3", "npm-package-arg": "^14.0.0", "pacote": "^22.0.0", "proc-log": "^7.0.0", diff --git a/workspaces/libnpmexec/lib/index.js b/workspaces/libnpmexec/lib/index.js index 4d7c126654689..89191484e21af 100644 --- a/workspaces/libnpmexec/lib/index.js +++ b/workspaces/libnpmexec/lib/index.js @@ -18,6 +18,7 @@ const noTTY = require('./no-tty.js') const runScript = require('./run-script.js') const isWindows = require('./is-windows.js') const withLock = require('./with-lock.js') +const { applyReleaseAgeExclude } = require('./release-age-exclude.js') // when checking the local tree we look up manifests, cache those results by // spec.raw so we don't have to fetch again when we check npxCache @@ -25,8 +26,15 @@ const manifests = new Map() const getManifest = async (spec, flatOptions) => { if (!manifests.has(spec.raw)) { + // Honor `min-release-age-exclude` for the top-level exec spec the + // same way arborist does inside `npm install`: if the requested + // package name matches the exclude list, drop the `before` cutoff + // that `min-release-age` set on flatOptions before handing the + // options to pacote. Without this, `npx @myscope/foo` fails with + // ETARGET even though `min-release-age-exclude=@myscope/*` is set. + const manifestOptions = applyReleaseAgeExclude(spec, flatOptions) const manifest = await pacote.manifest(spec, { - ...flatOptions, + ...manifestOptions, preferOnline: true, Arborist, _isRoot: true, diff --git a/workspaces/libnpmexec/lib/release-age-exclude.js b/workspaces/libnpmexec/lib/release-age-exclude.js new file mode 100644 index 0000000000000..4a9c6c0cee39b --- /dev/null +++ b/workspaces/libnpmexec/lib/release-age-exclude.js @@ -0,0 +1,56 @@ +// Mirrors the semantics used by @npmcli/arborist so `npx` honors +// `min-release-age-exclude` the same way `npm install` does. +// +// When the config layer sets `flatOptions.before` from `min-release-age`, +// pacote will refuse to resolve versions published after that cutoff. +// pacote itself has no notion of `min-release-age-exclude`; arborist +// applies it per-spec by dropping `before` for matched names. libnpmexec +// calls `pacote.manifest` directly, so it has to do the same. +// +// Patterns are exact names or minimatch globs against the resolved +// package name. Only the named package is exempt; its own dependencies +// still follow the release-age policy unless they also match a pattern. +const { minimatch } = require('minimatch') + +// Match arborist's hardened option set: `nonegate` keeps a leading `!` +// literal so a stray `!foo` exempts nothing instead of everything-but-foo, +// `nocomment` keeps `#` literal, and `noext` disables extglobs. This list +// only ever widens the exemption, so we never want a pattern feature to +// silently turn into match-all. +const minimatchOptions = { nonegate: true, nocomment: true, noext: true } + +const isReleaseAgeExcluded = (name, patterns) => { + if (!name || !Array.isArray(patterns) || patterns.length === 0) { + return false + } + return patterns.some(pattern => + name === pattern || minimatch(name, pattern, minimatchOptions)) +} + +// For `npm:` aliases the fetched package is the alias target, not the +// alias key, so the exemption must be keyed on the underlying name. +// Mirrors `trustedSpecName` in arborist's release-age-exclude.js. +const trustedSpecName = (spec) => { + if (!spec) { + return undefined + } + if (spec.type === 'alias' && spec.subSpec && spec.subSpec.registry) { + return spec.subSpec.name + } + return spec.name +} + +// Return a shallow copy of `flatOptions` with `before` cleared when the +// spec's trusted name matches an exclude pattern. Non-mutating so shared +// option objects aren't clobbered for later, unrelated specs. +const applyReleaseAgeExclude = (spec, flatOptions) => { + if (!flatOptions || flatOptions.before == null) { + return flatOptions + } + if (!isReleaseAgeExcluded(trustedSpecName(spec), flatOptions.minReleaseAgeExclude)) { + return flatOptions + } + return { ...flatOptions, before: null } +} + +module.exports = { isReleaseAgeExcluded, trustedSpecName, applyReleaseAgeExclude } diff --git a/workspaces/libnpmexec/package.json b/workspaces/libnpmexec/package.json index 4ed8d6a04214b..5c00b04a20bd6 100644 --- a/workspaces/libnpmexec/package.json +++ b/workspaces/libnpmexec/package.json @@ -69,6 +69,7 @@ "@npmcli/package-json": "^8.0.0", "@npmcli/run-script": "^11.0.0", "ci-info": "^4.0.0", + "minimatch": "^10.0.3", "npm-package-arg": "^14.0.0", "pacote": "^22.0.0", "proc-log": "^7.0.0", diff --git a/workspaces/libnpmexec/test/release-age-exclude.js b/workspaces/libnpmexec/test/release-age-exclude.js new file mode 100644 index 0000000000000..31a63d8fc0187 --- /dev/null +++ b/workspaces/libnpmexec/test/release-age-exclude.js @@ -0,0 +1,80 @@ +const t = require('tap') +const npa = require('npm-package-arg') +const { + isReleaseAgeExcluded, + trustedSpecName, + applyReleaseAgeExclude, +} = require('../lib/release-age-exclude.js') + +t.test('isReleaseAgeExcluded: empty / invalid patterns', async t => { + t.equal(isReleaseAgeExcluded('lodash', []), false) + t.equal(isReleaseAgeExcluded('lodash', undefined), false) + t.equal(isReleaseAgeExcluded('lodash', null), false) + t.equal(isReleaseAgeExcluded(undefined, ['*']), false) + t.equal(isReleaseAgeExcluded('', ['*']), false) +}) + +t.test('isReleaseAgeExcluded: exact and glob patterns', async t => { + t.equal(isReleaseAgeExcluded('lodash', ['lodash']), true, 'exact match') + t.equal(isReleaseAgeExcluded('lodash', ['other']), false, 'exact miss') + t.equal(isReleaseAgeExcluded('@myorg/foo', ['@myorg/*']), true, 'scope glob match') + t.equal(isReleaseAgeExcluded('@other/foo', ['@myorg/*']), false, 'scope glob miss') +}) + +t.test('isReleaseAgeExcluded: hardened glob semantics', async t => { + // `!foo` must NOT act as a negation exempting everything but foo. + t.equal(isReleaseAgeExcluded('bar', ['!foo']), false, + 'leading ! stays literal and does not exempt unrelated names') + // `#foo` must NOT be treated as a comment (which would match nothing). + t.equal(isReleaseAgeExcluded('#foo', ['#foo']), true, + 'leading # is literal') +}) + +t.test('trustedSpecName: registry, alias, and edge cases', async t => { + t.equal(trustedSpecName(undefined), undefined) + t.equal(trustedSpecName(npa('lodash@1.2.3')), 'lodash', 'registry spec') + // npm: alias — the fetched package is the alias target. + t.equal(trustedSpecName(npa('foo@npm:@myorg/real@1')), '@myorg/real', + 'alias resolves to underlying package name') +}) + +t.test('applyReleaseAgeExclude: no before => passthrough', async t => { + const opts = { before: null, minReleaseAgeExclude: ['@myorg/*'] } + t.equal(applyReleaseAgeExclude(npa('@myorg/foo@1'), opts), opts, + 'returns same object when there is no cutoff to clear') +}) + +t.test('applyReleaseAgeExclude: not excluded => passthrough', async t => { + const opts = { before: new Date('2026-01-01'), minReleaseAgeExclude: ['@myorg/*'] } + t.equal(applyReleaseAgeExclude(npa('lodash@1'), opts), opts, + 'unrelated package keeps the before cutoff') +}) + +t.test('applyReleaseAgeExclude: excluded => before cleared, non-mutating', async t => { + const before = new Date('2026-01-01') + const opts = { before, minReleaseAgeExclude: ['@myorg/*'], other: 'x' } + const out = applyReleaseAgeExclude(npa('@myorg/foo@1.2.3'), opts) + t.not(out, opts, 'returns a copy') + t.equal(opts.before, before, 'input untouched') + t.equal(out.before, null, 'before cleared for excluded spec') + t.equal(out.other, 'x', 'other options preserved') + t.same(out.minReleaseAgeExclude, ['@myorg/*'], 'exclude list preserved') +}) + +t.test('applyReleaseAgeExclude: alias excluded by alias-target name', async t => { + const opts = { + before: new Date('2026-01-01'), + minReleaseAgeExclude: ['@myorg/real'], + } + const out = applyReleaseAgeExclude(npa('foo@npm:@myorg/real@1'), opts) + t.equal(out.before, null, + 'alias exemption keyed on underlying package name, not alias key') +}) + +t.test('applyReleaseAgeExclude: null / empty options', async t => { + t.equal(applyReleaseAgeExclude(npa('lodash@1'), null), null) + t.equal(applyReleaseAgeExclude(npa('lodash@1'), undefined), undefined) + const opts = {} + t.equal(applyReleaseAgeExclude(npa('lodash@1'), opts), opts, + 'no before => passthrough') +}) From add0b2116d25d2ed91a11129edacdb9d42cdbbb3 Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:15:37 -0700 Subject: [PATCH 2/2] fix(libnpmexec): scope manifest cache to each exec call The manifest lookup cache was module-scoped and keyed only by spec.raw, while min-release-age-exclude is applied only on a cache miss. The first exec() for a spec matching the exclude list resolved and cached a manifest with the `before` cutoff dropped; a later exec() for the same spec without the exclusion reused that cached manifest and bypassed its release-age restriction. Move the cache into a per-exec() Map and thread it through getManifest and all three missingFromTree() call sites, so caching still avoids duplicate fetches within a single exec() while policy-specific results no longer leak across separate exec() invocations. --- workspaces/libnpmexec/lib/index.js | 31 +++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/workspaces/libnpmexec/lib/index.js b/workspaces/libnpmexec/lib/index.js index 89191484e21af..9459e100a8047 100644 --- a/workspaces/libnpmexec/lib/index.js +++ b/workspaces/libnpmexec/lib/index.js @@ -21,11 +21,12 @@ const withLock = require('./with-lock.js') const { applyReleaseAgeExclude } = require('./release-age-exclude.js') // when checking the local tree we look up manifests, cache those results by -// spec.raw so we don't have to fetch again when we check npxCache -const manifests = new Map() - -const getManifest = async (spec, flatOptions) => { - if (!manifests.has(spec.raw)) { +// spec.raw so we don't have to fetch again when we check npxCache. This cache +// is scoped to a single exec() call (see `manifestCache` below) so that +// policy-specific results (e.g. `min-release-age-exclude`) don't leak across +// separate exec() invocations for the same spec. +const getManifest = async (spec, flatOptions, manifestCache) => { + if (!manifestCache.has(spec.raw)) { // Honor `min-release-age-exclude` for the top-level exec spec the // same way arborist does inside `npm install`: if the requested // package name matches the exclude list, drop the `before` cutoff @@ -39,14 +40,14 @@ const getManifest = async (spec, flatOptions) => { Arborist, _isRoot: true, }) - manifests.set(spec.raw, manifest) + manifestCache.set(spec.raw, manifest) } - return manifests.get(spec.raw) + return manifestCache.get(spec.raw) } // Returns the required manifest if the spec is missing from the tree // Returns the found node if it is in the tree -const missingFromTree = async ({ spec, tree, flatOptions, isNpxTree, shallow }) => { +const missingFromTree = async ({ spec, tree, flatOptions, isNpxTree, shallow, manifestCache }) => { // If asking for a spec by name only (spec.raw === spec.name): // - In local or global mode go with anything in the tree that matches // - If looking in the npx cache check if a newer version is available @@ -73,12 +74,12 @@ const missingFromTree = async ({ spec, tree, flatOptions, isNpxTree, shallow }) return { node } } } - const manifest = await getManifest(spec, flatOptions) + const manifest = await getManifest(spec, flatOptions, manifestCache) return { manifest } } else { // non-registry spec, or a specific tag, or name only in npx tree. Look up // manifest and check resolved to see if it's in the tree. - const manifest = await getManifest(spec, flatOptions) + const manifest = await getManifest(spec, flatOptions, manifestCache) if (spec.type === 'directory' && !isNpxTree) { return { manifest } } @@ -121,6 +122,12 @@ const exec = async (opts) => { const binPaths = [] + // Scope the manifest cache to this exec() call. Caching within a single + // exec() avoids duplicate fetches (local tree + npx tree checks) while + // ensuring policy-specific results (e.g. `min-release-age-exclude`) don't + // leak into a later exec() call for the same spec. + const manifestCache = new Map() + let pkgPaths = opts.pkgPath if (typeof pkgPaths === 'string') { pkgPaths = [pkgPaths] @@ -208,6 +215,7 @@ const exec = async (opts) => { spec, tree: localTree, flatOptions, + manifestCache, }) if (manifest) { // Package does not exist in the local tree @@ -239,7 +247,7 @@ const exec = async (opts) => { }) if (globalTree) { const { manifest: globalManifest } = - await missingFromTree({ spec, tree: globalTree, flatOptions, shallow: true }) + await missingFromTree({ spec, tree: globalTree, flatOptions, shallow: true, manifestCache }) if (!globalManifest && await fileExists(`${globalBin}/${args[0]}`)) { binPaths.push(globalBin) return await run() @@ -281,6 +289,7 @@ const exec = async (opts) => { tree: npxTree, flatOptions, isNpxTree: true, + manifestCache, }) if (manifest) { // Manifest is not in npxCache, we need to install it there