Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions workspaces/arborist/lib/arborist/reify.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -705,13 +706,24 @@ 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) {
res = `${node.name}@${registryResolved}`
}
} 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.
Expand Down Expand Up @@ -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) {
Expand Down
71 changes: 71 additions & 0 deletions workspaces/arborist/test/arborist/reify.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')))
Expand Down