Skip to content

fix(libnpmexec): honor min-release-age-exclude in npx - #9768

Open
Sanjays2402 wants to merge 2 commits into
npm:latestfrom
Sanjays2402:fix/issue-9765
Open

fix(libnpmexec): honor min-release-age-exclude in npx#9768
Sanjays2402 wants to merge 2 commits into
npm:latestfrom
Sanjays2402:fix/issue-9765

Conversation

@Sanjays2402

Copy link
Copy Markdown

Description

npx calls pacote.manifest(spec, { ...flatOptions }) directly, which forwards flatOptions.before (set by min-release-age) to pacote. pacote has no concept of min-release-age-exclude, so even packages whose name matches an exclude pattern are rejected with ETARGET. arborist already handles this per-spec inside npm install by dropping before when the resolved package name matches; libnpmexec needs to do the same.

Change in workspaces/libnpmexec/lib/index.js: before calling pacote.manifest in getManifest, run the spec through a new helper (applyReleaseAgeExclude) that returns a shallow copy of flatOptions with before set to null when the spec's trusted registry name matches minReleaseAgeExclude. npm: aliases are keyed on the alias target, mirroring arborist's trustedSpecName. The pattern semantics match arborist exactly (nonegate, nocomment, noext) so an exemption list can never silently widen into match-all.

Repro before the fix, with ~/.npmrc:

min-release-age=3
min-release-age-exclude[]=@myscope/*
$ npx @myscope/some-package@1.2.3
npm error code ETARGET
npm error notarget No matching version found for @myscope/some-package@1.2.3 with a date before <cutoff>.

After the fix, the exempt package resolves normally; unrelated packages still respect the cutoff.

Existing Issue

Fixes #9765

Test Coverage

New unit tests in workspaces/libnpmexec/test/release-age-exclude.js cover the helper end to end: exact and glob patterns, hardened !/#/extglob semantics, npm: alias resolution, non-mutation of the input options, and the passthrough branches (no before, no match, empty/undefined options). File coverage is 100%. The existing libnpmexec suite continues to pass, since the wire-in is a passthrough whenever before is unset (the norm in existing tests).

Verified both directions on the affected test file: it fails on main (module missing) and passes with the patch applied.

AI Disclosure

This change was written with the assistance of Claude. I have reviewed the diff and the test and take responsibility for the code.

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: npm#9765
@Sanjays2402
Sanjays2402 requested review from a team as code owners July 15, 2026 09:24
Comment thread workspaces/libnpmexec/lib/index.js Outdated

// 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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding min-release-age-exclude support to npx. I found one cache-lifetime issue that should be addressed before merging.

manifests is module-scoped cache and keyed only by spec.raw, while applyReleaseAgeExclude() runs only on a cache miss. If the first libnpmexec call excludes a package, it can cache a post-cutoff manifest resolved with before: null. A later call for the same package without the exclusion then reuses that manifest and bypasses its release-age restriction.

I reproduced this with two sequential calls: both executed the newer version, although the second call should have selected the older, cutoff-eligible version.

Could we scope the manifest cache to each exec() invocation and pass it through all three missingFromTree()? That preserves caching between the local and npx-tree checks while preventing policy-specific results from leaking across calls.

const getManifest = async (spec, flatOptions, manifestCache) => {
  if (!manifestCache.has(spec.raw)) {
    ...
    manifestCache.set(spec.raw, manifest)
  }
  return manifestCache.get(spec.raw)
}

...

const missingFromTree = async ({
  spec,
  tree,
  flatOptions,
  manifestCache,
  isNpxTree,
  shallow,
}) => {
   // Existing tree-checking logic...
    const manifest = await getManifest(spec, flatOptions, manifestCache)
     return { manifest }
   } else {
    const manifest = await getManifest(spec, flatOptions, manifestCache)

     // Existing logic...
   }
 }

Please also add a two-call regression test covering this scenario.

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.
@Sanjays2402

Copy link
Copy Markdown
Author

Good catch, thanks — the leak is real and your reproduction matches what I see.

I've pushed the cache scoping in add0b21. It follows your sketch: manifestCache is now a Map created inside exec() and threaded through getManifest() and all three missingFromTree() call sites, so the local-tree and npx-tree lookups still share a single fetch within one exec() while applyReleaseAgeExclude() is re-evaluated per invocation.

On the two-call regression test — I don't have one I'm willing to push yet, and I'd rather say that than push something that only looks like coverage. The shapes I tried:

  1. Full setup() + MockRegistry, two sequential exec() calls with a local install so the spec resolves without a reify. pacote.manifest gets called ~6 times because arborist's loadActual/resolution path goes through it too, so filtering by spec.raw to isolate the top-level lookup was fragile, and the first call's recorded before didn't come back null — I think I'm not threading minReleaseAgeExclude into flatOptions the same way the config layer does in that harness.
  2. Mocking pacote.manifest to record { raw, before } and throw immediately. That passes, but it passes without the fix too, since throwing before manifestCache.set() means nothing is ever cached — so it's not a regression test at all.

The thing that makes this awkward under t.mock is that the pre-fix cache is module scoped, so the test has to reuse one mocked module instance across both exec() calls (re-mocking per call resets it and hides the bug) while still driving each call far enough to populate the cache.

Do you have a preference for the shape here? Either:

  • a full MockRegistry test in test/registry.js with two real versions and a real before cutoff, asserting the second call selects the older version; or
  • exporting getManifest/missingFromTree (or accepting the cache as a param on an internal export) so the cache behaviour can be unit tested directly without standing up a registry.

I'd lean toward the first since it tests the actual user-visible symptom, but it's the more expensive one to get deterministic. Happy to write whichever you'd rather review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] min-release-age-exclude is not honored by npx

2 participants