diff --git a/lib/commands/install.js b/lib/commands/install.js index 0bc3591d4af73..11bdb1b76c2cd 100644 --- a/lib/commands/install.js +++ b/lib/commands/install.js @@ -39,6 +39,7 @@ class Install extends ArboristWorkspaceCmd { 'audit', 'before', 'min-release-age', + 'min-release-age-exclude', 'bin-links', 'fund', 'dry-run', diff --git a/lib/commands/outdated.js b/lib/commands/outdated.js index 882ad2cc9d28a..714b3ab64da4d 100644 --- a/lib/commands/outdated.js +++ b/lib/commands/outdated.js @@ -4,6 +4,7 @@ const pacote = require('pacote') const table = require('text-table') const npa = require('npm-package-arg') const pickManifest = require('npm-pick-manifest') +const { isReleaseAgeExcluded } = require('@npmcli/arborist/lib/release-age-exclude.js') const { output } = require('proc-log') const localeCompare = require('@isaacs/string-locale-compare')('en') const ArboristWorkspaceCmd = require('../arborist-cmd.js') @@ -32,6 +33,7 @@ class Outdated extends ArboristWorkspaceCmd { 'workspace', 'before', 'min-release-age', + 'min-release-age-exclude', ] #tree @@ -183,8 +185,14 @@ class Outdated extends ArboristWorkspaceCmd { try { const packument = await this.#getPackument(spec) const expected = alias ? alias.fetchSpec : edge.spec - const wanted = pickManifest(packument, expected, this.npm.flatOptions) - const latest = pickManifest(packument, '*', this.npm.flatOptions) + const { minReleaseAgeExclude } = this.npm.flatOptions + // Packages matching `min-release-age-exclude` resolve to their newest + // version, so drop the `before` constraint for them. + const pickOpts = isReleaseAgeExcluded(packument.name, minReleaseAgeExclude) + ? { ...this.npm.flatOptions, before: null } + : this.npm.flatOptions + const wanted = pickManifest(packument, expected, pickOpts) + const latest = pickManifest(packument, '*', pickOpts) if (!current || current !== wanted.version || wanted.version !== latest.version) { this.#list.push({ name: alias ? edge.spec.replace('npm', edge.name) : edge.name, diff --git a/lib/commands/query.js b/lib/commands/query.js index 5e70e25f32e62..63bd00c3206b3 100644 --- a/lib/commands/query.js +++ b/lib/commands/query.js @@ -49,6 +49,9 @@ class Query extends BaseCommand { 'include-workspace-root', 'package-lock-only', 'expect-results', + 'before', + 'min-release-age', + 'min-release-age-exclude', ] constructor (...args) { diff --git a/lib/commands/update.js b/lib/commands/update.js index 22f77390b25a3..38f87c4a3e218 100644 --- a/lib/commands/update.js +++ b/lib/commands/update.js @@ -27,6 +27,7 @@ class Update extends ArboristWorkspaceCmd { 'audit', 'before', 'min-release-age', + 'min-release-age-exclude', 'bin-links', 'fund', 'dry-run', diff --git a/tap-snapshots/test/lib/commands/config.js.test.cjs b/tap-snapshots/test/lib/commands/config.js.test.cjs index 829b64b3f800b..84a67b2309183 100644 --- a/tap-snapshots/test/lib/commands/config.js.test.cjs +++ b/tap-snapshots/test/lib/commands/config.js.test.cjs @@ -112,6 +112,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "maxsockets": 15, "message": "%s", "min-release-age": null, + "min-release-age-exclude": [], "node-gyp": "{CWD}/node_modules/node-gyp/bin/node-gyp.js", "node-options": null, "noproxy": [ @@ -301,6 +302,7 @@ logs-max = 10 maxsockets = 15 message = "%s" min-release-age = null +min-release-age-exclude = [] name = null node-gyp = "{CWD}/node_modules/node-gyp/bin/node-gyp.js" node-options = null diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index a8a72cb50e25c..1dd3b0d7e6b5c 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -404,6 +404,9 @@ sources, the standard precedence applies (cli > env > project > user > global), so a higher-priority source can always relax or override a lower-priority one. +Packages whose names match \`min-release-age-exclude\` are exempt from this +filter. + #### \`bin-links\` @@ -1276,6 +1279,37 @@ your \`.npmrc\` is preserved when npm internally spawns a sub-process with apply, \`before\` wins within a single source and across sources the standard precedence rules apply. +Packages whose names match \`min-release-age-exclude\` are exempt from this +filter. + +This value is not exported to the environment for child processes. + +#### \`min-release-age-exclude\` + +* Default: +* Type: String (can be set multiple times) + +A list of package names or \`minimatch\` glob patterns that are exempt from +the \`min-release-age\` (and \`before\`) filter. A matching package can always +resolve to its newest version, even when a release-age window is set. + +For example, to apply a release-age window to third-party dependencies while +letting internally maintained packages update immediately: + +\`\`\` +min-release-age=7 +min-release-age-exclude[]=@myorg/* +min-release-age-exclude[]=my-internal-pkg +\`\`\` + +Only the named package is exempt; its own dependencies still follow the +release-age policy unless they also match a pattern. Patterns match against +the package name, so \`@myorg/*\` matches \`@myorg/shared-utils\`. + +Excluding a package does not change which registry it is fetched from. You +should own your private scope on the public registry so that nobody else can +publish a package with the same name. + This value is not exported to the environment for child processes. #### \`name\` @@ -2498,6 +2532,7 @@ Array [ "maxsockets", "message", "min-release-age", + "min-release-age-exclude", "node-gyp", "node-options", "noproxy", @@ -2664,6 +2699,7 @@ Array [ "maxsockets", "message", "min-release-age", + "min-release-age-exclude", "node-gyp", "noproxy", "offline", @@ -2841,6 +2877,7 @@ Object { "logColor": false, "maxSockets": 15, "message": "%s", + "minReleaseAgeExclude": Array [], "name": null, "nodeBin": "{NODE}", "nodeGyp": "{CWD}/node_modules/node-gyp/bin/node-gyp.js", @@ -4252,8 +4289,10 @@ Options: [--allow-remote ] [--allow-scripts [--allow-scripts ...]] [--strict-allow-scripts] [--dangerously-allow-all-scripts] [--no-audit] -[--before ] [--min-release-age ] [--no-bin-links] [--no-fund] -[--dry-run] [--cpu ] [--os ] [--libc ] +[--before ] [--min-release-age ] +[--min-release-age-exclude [--min-release-age-exclude ...]] +[--no-bin-links] [--no-fund] [--dry-run] [--cpu ] [--os ] +[--libc ] [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] @@ -4329,6 +4368,9 @@ Options: --min-release-age If set, npm will build the npm tree such that only versions that were + --min-release-age-exclude + A list of package names or \`minimatch\` glob patterns that are exempt + --bin-links Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package @@ -4394,6 +4436,7 @@ aliases: add, i, in, ins, inst, insta, instal, isnt, isnta, isntal, isntall #### \`audit\` #### \`before\` #### \`min-release-age\` +#### \`min-release-age-exclude\` #### \`bin-links\` #### \`fund\` #### \`dry-run\` @@ -4548,8 +4591,10 @@ Options: [--allow-remote ] [--allow-scripts [--allow-scripts ...]] [--strict-allow-scripts] [--dangerously-allow-all-scripts] [--no-audit] -[--before ] [--min-release-age ] [--no-bin-links] [--no-fund] -[--dry-run] [--cpu ] [--os ] [--libc ] +[--before ] [--min-release-age ] +[--min-release-age-exclude [--min-release-age-exclude ...]] +[--no-bin-links] [--no-fund] [--dry-run] [--cpu ] [--os ] +[--libc ] [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] @@ -4625,6 +4670,9 @@ Options: --min-release-age If set, npm will build the npm tree such that only versions that were + --min-release-age-exclude + A list of package names or \`minimatch\` glob patterns that are exempt + --bin-links Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package @@ -4690,6 +4738,7 @@ alias: it #### \`audit\` #### \`before\` #### \`min-release-age\` +#### \`min-release-age-exclude\` #### \`bin-links\` #### \`fund\` #### \`dry-run\` @@ -5136,6 +5185,7 @@ Options: [-a|--all] [--json] [-l|--long] [-p|--parseable] [-g|--global] [-w|--workspace [-w|--workspace ...]] [--before ] [--min-release-age ] +[--min-release-age-exclude [--min-release-age-exclude ...]] -a|--all Show or act on all packages, not just the ones your project directly @@ -5161,6 +5211,9 @@ Options: --min-release-age If set, npm will build the npm tree such that only versions that were + --min-release-age-exclude + A list of package names or \`minimatch\` glob patterns that are exempt + Run "npm help outdated" for more info @@ -5176,6 +5229,7 @@ npm outdated [ ...] #### \`workspace\` #### \`before\` #### \`min-release-age\` +#### \`min-release-age-exclude\` ` exports[`test/lib/docs.js TAP usage owner > must match snapshot 1`] = ` @@ -5531,7 +5585,9 @@ Options: [-g|--global] [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--package-lock-only] -[--expect-results|--expect-result-count ] +[--expect-results|--expect-result-count ] [--before ] +[--min-release-age ] +[--min-release-age-exclude [--min-release-age-exclude ...]] -g|--global Operates in "global" mode, so that packages are installed into the @@ -5551,6 +5607,15 @@ Options: --expect-results Tells npm whether or not to expect results from the command. + --before + If passed to \`npm install\`, will rebuild the npm tree such that only + + --min-release-age + If set, npm will build the npm tree such that only versions that were + + --min-release-age-exclude + A list of package names or \`minimatch\` glob patterns that are exempt + Run "npm help query" for more info @@ -5565,6 +5630,9 @@ npm query #### \`package-lock-only\` #### \`expect-results\` #### \`expect-result-count\` +#### \`before\` +#### \`min-release-age\` +#### \`min-release-age-exclude\` ` exports[`test/lib/docs.js TAP usage rebuild > must match snapshot 1`] = ` @@ -6477,8 +6545,9 @@ Options: [--ignore-scripts] [--allow-scripts [--allow-scripts ...]] [--strict-allow-scripts] [--dangerously-allow-all-scripts] [--no-audit] -[--before ] [--min-release-age ] [--no-bin-links] [--no-fund] -[--dry-run] +[--before ] [--min-release-age ] +[--min-release-age-exclude [--min-release-age-exclude ...]] +[--no-bin-links] [--no-fund] [--dry-run] [-w|--workspace [-w|--workspace ...]] [--workspaces] [--include-workspace-root] [--install-links] @@ -6533,6 +6602,9 @@ Options: --min-release-age If set, npm will build the npm tree such that only versions that were + --min-release-age-exclude + A list of package names or \`minimatch\` glob patterns that are exempt + --bin-links Tells npm to create symlinks (or \`.cmd\` shims on Windows) for package @@ -6582,6 +6654,7 @@ aliases: u, up, upgrade, udpate #### \`audit\` #### \`before\` #### \`min-release-age\` +#### \`min-release-age-exclude\` #### \`bin-links\` #### \`fund\` #### \`dry-run\` diff --git a/test/lib/commands/outdated.js b/test/lib/commands/outdated.js index e3709bf7c2b41..34b8ea75190fc 100644 --- a/test/lib/commands/outdated.js +++ b/test/lib/commands/outdated.js @@ -57,6 +57,24 @@ const packument = spec => { }, }, }, + timed: { + name: 'timed', + 'dist-tags': { + latest: '2.0.0', + }, + versions: { + '1.0.0': { + version: '1.0.0', + }, + '2.0.0': { + version: '2.0.0', + }, + }, + time: { + '1.0.0': '2020-01-01T00:00:00.000Z', + '2.0.0': '2020-06-01T00:00:00.000Z', + }, + }, } if (spec.name === 'eta') { @@ -731,3 +749,57 @@ t.test('dependent location', async t => { ) }) }) + +t.test('min-release-age-exclude', async t => { + const prefixDir = { + 'package.json': JSON.stringify({ + name: 'project', + version: '1.0.0', + dependencies: { + timed: '^1.0.0', + }, + }, null, 2), + node_modules: { + timed: { + 'package.json': JSON.stringify({ + name: 'timed', + version: '1.0.0', + }, null, 2), + }, + }, + } + + await t.test('before hides the newer version', async t => { + const { outdated, joinedOutput } = await mockNpm(t, { + prefixDir, + config: { before: new Date('2020-03-01') }, + }) + await outdated.exec([]) + t.notMatch(joinedOutput(), 'timed', 'newer version filtered out by before') + }) + + await t.test('exact-name exclude restores the newer version', async t => { + const { outdated, joinedOutput } = await mockNpm(t, { + prefixDir, + config: { + before: new Date('2020-03-01'), + 'min-release-age-exclude': ['timed'], + }, + }) + await outdated.exec([]) + t.match(joinedOutput(), 'timed', 'excluded package is reported as outdated') + t.match(joinedOutput(), '2.0.0', 'latest 2.0.0 is surfaced') + }) + + await t.test('glob exclude restores the newer version', async t => { + const { outdated, joinedOutput } = await mockNpm(t, { + prefixDir, + config: { + before: new Date('2020-03-01'), + 'min-release-age-exclude': ['tim*'], + }, + }) + await outdated.exec([]) + t.match(joinedOutput(), '2.0.0', 'glob-excluded package shows newer version') + }) +}) diff --git a/workspaces/arborist/lib/arborist/build-ideal-tree.js b/workspaces/arborist/lib/arborist/build-ideal-tree.js index 105056baf66b9..68527972b1fc3 100644 --- a/workspaces/arborist/lib/arborist/build-ideal-tree.js +++ b/workspaces/arborist/lib/arborist/build-ideal-tree.js @@ -24,6 +24,7 @@ const PlaceDep = require('../place-dep.js') const debug = require('../debug.js') const fromPath = require('../from-path.js') const calcDepFlags = require('../calc-dep-flags.js') +const { isReleaseAgeExcluded, trustedSpecName } = require('../release-age-exclude.js') const Shrinkwrap = require('../shrinkwrap.js') const { defaultLockfileVersion } = Shrinkwrap const Node = require('../node.js') @@ -533,7 +534,11 @@ module.exports = cls => class IdealTreeBuilder extends cls { // look up the names of file/directory/git specs if (!spec.name || isTag) { const _isRoot = tree.isProjectRoot || tree.isWorkspace - const mani = await pacote.manifest(spec, { ...this.options, _isRoot }) + const mani = await pacote.manifest(spec, { + ...this.options, + _isRoot, + before: this.#releaseAgeBefore(spec), + }) if (isTag) { // translate tag to a version spec = npa(`${mani.name}@${mani.version}`) @@ -777,6 +782,7 @@ This is a one-time fix-up, please be patient... resolved: resolved, integrity: integrity, fullMetadata: false, + before: this.#releaseAgeBefore(spec), }) node.package = { ...mani, _id: `${mani.name}@${mani.version}` } } catch (er) { @@ -1279,6 +1285,19 @@ This is a one-time fix-up, please be patient... return problems } + // The effective `before` filter for a package, applying `min-release-age-exclude`. + // Returns null (no age filter) for an exempted package, otherwise the + // configured `before`. The exemption is keyed on the spec's trusted registry + // identity (alias targets are unwrapped) so an `npm:` alias key cannot disable + // the filter for the package it actually resolves to. + #releaseAgeBefore (spec) { + const { before, minReleaseAgeExclude } = this.options + if (!before) { + return before + } + return isReleaseAgeExcluded(trustedSpecName(spec), minReleaseAgeExclude) ? null : before + } + async #fetchManifest (spec, parent, edge) { // Enforce allow-* gates before consulting the manifest cache so a cached entry from a different edge cannot bypass the policy. this.#checkAllow(spec, edge) @@ -1286,6 +1305,7 @@ This is a one-time fix-up, please be patient... ...this.options, avoid: this.#avoidRange(spec.name), fullMetadata: true, + before: this.#releaseAgeBefore(spec), _isRoot: !!(edge?.from?.isProjectRoot || edge?.from?.isWorkspace), } // get the intended spec and stored metadata from yarn.lock file, diff --git a/workspaces/arborist/lib/query-selector-all.js b/workspaces/arborist/lib/query-selector-all.js index 626af0c908e9c..3a6c15139d40b 100644 --- a/workspaces/arborist/lib/query-selector-all.js +++ b/workspaces/arborist/lib/query-selector-all.js @@ -9,6 +9,7 @@ const npa = require('npm-package-arg') const pacote = require('pacote') const semver = require('semver') const npmFetch = require('npm-registry-fetch') +const { isReleaseAgeExcluded } = require('./release-age-exclude.js') // handle results for parsed query asts, results are stored in a map that has a // key that points to each ast selector node and stores the resulting array of @@ -889,8 +890,9 @@ const getPackageVersions = async (name, opts) => { let candidates = Object.keys(packument.versions).sort(semver.compare) // if the packument has a time property, and the user passed a before flag, then - // we filter this list down to only those versions that existed before the specified date - if (packument.time && opts.before) { + // we filter this list down to only those versions that existed before the specified date. + // packages matching `min-release-age-exclude` are exempt from this filter. + if (packument.time && opts.before && !isReleaseAgeExcluded(name, opts.minReleaseAgeExclude)) { candidates = candidates.filter((version) => { // this version isn't found in the times at all, drop it if (!packument.time[version]) { diff --git a/workspaces/arborist/lib/release-age-exclude.js b/workspaces/arborist/lib/release-age-exclude.js new file mode 100644 index 0000000000000..b85527cfcbede --- /dev/null +++ b/workspaces/arborist/lib/release-age-exclude.js @@ -0,0 +1,45 @@ +// Determine whether a package name is exempt from the `min-release-age` / +// `before` release-age filter, based on the `min-release-age-exclude` config. +// +// Patterns are exact package names or `minimatch` globs (e.g. `@myorg/*`), and +// match against the package name only. This is a "named-only" exemption: a +// matched package's own dependencies still follow the filter unless they match +// a pattern too. +// +// Callers must match against the resolved registry identity of a package, not +// the self-reported alias or dependency-edge name. For `npm:` aliases the +// fetched package is the alias target, so run specs through `trustedSpecName` +// first; otherwise an alias key could match an exclude pattern and turn the +// filter off for the unrelated package it resolves to. +const { minimatch } = require('minimatch') + +// This list only ever widens the exemption (turns the security filter off for a +// package), so disable pattern features that could silently turn it into a +// match-all: `nonegate` keeps a leading `!` literal (so a stray `!foo` exempts +// nothing instead of everything-but-foo), `nocomment` keeps a leading `#` +// literal, and `noext` disables extglobs. +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)) +} + +// Resolve the trusted registry name for an npa spec. For `npm:` aliases (e.g. +// `"x": "npm:other@1"`) the installed/fetched package is the alias target +// (`subSpec`), not the alias key, so the exemption must be keyed on the +// underlying package name. Mirrors `nameFromEdges` in script-allowed.js. +const trustedSpecName = (spec) => { + if (!spec) { + return undefined + } + if (spec.type === 'alias' && spec.subSpec && spec.subSpec.registry) { + return spec.subSpec.name + } + return spec.name +} + +module.exports = { isReleaseAgeExcluded, trustedSpecName } diff --git a/workspaces/arborist/test/arborist/build-ideal-tree.js b/workspaces/arborist/test/arborist/build-ideal-tree.js index fe0092c6d386a..1abd022a6723e 100644 --- a/workspaces/arborist/test/arborist/build-ideal-tree.js +++ b/workspaces/arborist/test/arborist/build-ideal-tree.js @@ -2942,6 +2942,69 @@ t.test('avoid dedupe when a dep is bundled', async t => { }) }) +t.test('min-release-age-exclude exempts matched packages from the before filter', async t => { + // The dupes-b fixture publishes 2.0.0 at 16:23:59 and 2.1.0 at 16:25:15. + // A `before` of 16:24:00 normally filters 2.1.0 out, leaving 2.0.0. + const before = new Date('2021-04-23T16:24:00Z') + const pkg = '@isaacs/testing-bundle-dupes-b' + const mkPath = () => t.testdir({ + 'package.json': JSON.stringify({ + dependencies: { [pkg]: '2' }, + }), + }) + + await t.test('without exclude, before filters to the older version', async t => { + createRegistry(t, true) + const tree = await buildIdeal(mkPath(), { before }) + t.equal(tree.children.get(pkg).version, '2.0.0', 'before filter applied') + }) + + await t.test('exact name in exclude bypasses the before filter', async t => { + createRegistry(t, true) + const tree = await buildIdeal(mkPath(), { + before, + minReleaseAgeExclude: [pkg], + }) + t.equal(tree.children.get(pkg).version, '2.1.0', 'newest version installed') + }) + + await t.test('glob pattern in exclude bypasses the before filter', async t => { + createRegistry(t, true) + const tree = await buildIdeal(mkPath(), { + before, + minReleaseAgeExclude: ['@isaacs/*'], + }) + t.equal(tree.children.get(pkg).version, '2.1.0', 'newest version installed') + }) + + await t.test('non-matching exclude leaves the before filter in place', async t => { + createRegistry(t, true) + const tree = await buildIdeal(mkPath(), { + before, + minReleaseAgeExclude: ['some-other-pkg', '@other/*'], + }) + t.equal(tree.children.get(pkg).version, '2.0.0', 'before filter still applied') + }) + + await t.test('an npm: alias key cannot bypass the filter for its target', async t => { + // The exclude must match the resolved registry identity, not the alias key. + // Here the alias key `dupes` matches the exclude but the fetched package + // `pkg` does not, so the before filter must still apply. + createRegistry(t, true) + const aliasPath = t.testdir({ + 'package.json': JSON.stringify({ + dependencies: { dupes: `npm:${pkg}@2` }, + }), + }) + const tree = await buildIdeal(aliasPath, { + before, + minReleaseAgeExclude: ['dupes'], + }) + t.equal(tree.children.get('dupes').version, '2.0.0', + 'before filter still applied to the aliased package') + }) +}) + t.test('upgrade a partly overlapping peer set', async t => { const path = t.testdir({ 'package.json': JSON.stringify({ diff --git a/workspaces/arborist/test/query-selector-all.js b/workspaces/arborist/test/query-selector-all.js index 70f14c302c1c6..3886efc334d0c 100644 --- a/workspaces/arborist/test/query-selector-all.js +++ b/workspaces/arborist/test/query-selector-all.js @@ -1035,9 +1035,37 @@ t.test('query-selector-all', async t => { ['#a, #bar:semver(2), #foo:semver(2.2.2)', ['a@1.0.0', 'bar@2.0.0', 'foo@2.2.2']], ['#b *', ['a@1.0.0', 'bar@2.0.0', 'baz@1.0.0', 'lorem@1.0.0', 'moo@3.0.0']], ]) -}) -// Simulates the linked install strategy layout where packages live in node_modules/.store/ and are symlinked from node_modules/. + // :outdated combined with --before and min-release-age-exclude. + // bar's 2.0.0 was published today, so `before: yesterday` normally filters it + // out and bar drops off the outdated list. Excluding bar bypasses the filter + // for that package so its newer version is considered again. + await t.test(':outdated honors min-release-age-exclude (exact name)', async t => { + const res = await querySelectorAll(tree, ':outdated', { + before: yesterday, + minReleaseAgeExclude: ['bar'], + }) + t.same(res, [ + 'abbrev@1.1.1', + 'baz@1.0.0', + 'dash-separated-pkg@1.0.0', + 'bar@1.4.0', // not filtered by before because bar is excluded + ], 'excluded package is not filtered by before') + }) + + await t.test(':outdated honors min-release-age-exclude (glob)', async t => { + const res = await querySelectorAll(tree, ':outdated', { + before: yesterday, + minReleaseAgeExclude: ['b*'], + }) + t.same(res, [ + 'abbrev@1.1.1', + 'baz@1.0.0', + 'dash-separated-pkg@1.0.0', + 'bar@1.4.0', // glob matches bar, bypassing the before filter + ], 'glob pattern excludes bar from the before filter') + }) +}) t.test('linked strategy: :root > * excludes transitive deps and store nodes', async t => { /* fixture tree (linked strategy layout): diff --git a/workspaces/arborist/test/release-age-exclude.js b/workspaces/arborist/test/release-age-exclude.js new file mode 100644 index 0000000000000..71b15a1ab7997 --- /dev/null +++ b/workspaces/arborist/test/release-age-exclude.js @@ -0,0 +1,71 @@ +const t = require('tap') +const npa = require('npm-package-arg') +const { isReleaseAgeExcluded, trustedSpecName } = require('../lib/release-age-exclude.js') + +t.test('returns false when there are no patterns', t => { + t.equal(isReleaseAgeExcluded('lodash', []), false, 'empty array') + t.equal(isReleaseAgeExcluded('lodash', undefined), false, 'undefined') + t.equal(isReleaseAgeExcluded('lodash', null), false, 'null') + t.end() +}) + +t.test('returns false when the name is missing', t => { + t.equal(isReleaseAgeExcluded(undefined, ['*']), false, 'undefined name') + t.equal(isReleaseAgeExcluded('', ['*']), false, 'empty name') + t.end() +}) + +t.test('matches exact package names', t => { + t.equal(isReleaseAgeExcluded('lodash', ['lodash']), true, 'unscoped exact') + t.equal(isReleaseAgeExcluded('@myorg/utils', ['@myorg/utils']), true, 'scoped exact') + t.equal(isReleaseAgeExcluded('react', ['lodash', 'react', 'vue']), true, 'one of many') + t.equal(isReleaseAgeExcluded('react-dom', ['react']), false, 'no partial match') + t.end() +}) + +t.test('matches glob patterns', t => { + t.equal(isReleaseAgeExcluded('@myorg/utils', ['@myorg/*']), true, 'scope wildcard') + t.equal(isReleaseAgeExcluded('@myorg/shared-utils', ['@myorg/*']), true, 'scope wildcard 2') + t.equal(isReleaseAgeExcluded('@other/utils', ['@myorg/*']), false, 'different scope') + t.equal(isReleaseAgeExcluded('lodash', ['lo*']), true, 'prefix wildcard') + t.equal(isReleaseAgeExcluded('react', ['@myorg/*', 'lodash']), false, 'no pattern matches') + t.end() +}) + +t.test('negation and comment patterns cannot invert into a match-all', t => { + // A leading `!` must be treated literally (no package name can contain `!`), + // so it exempts nothing rather than everything-but-the-listed-name. Likewise + // a leading `#` must not be swallowed as a comment. + t.equal(isReleaseAgeExcluded('lodash', ['!react']), false, 'negation does not match others') + t.equal(isReleaseAgeExcluded('react', ['!react']), false, 'negation does not match itself') + t.equal(isReleaseAgeExcluded('lodash', ['#lodash']), false, 'comment matches nothing') + t.end() +}) + +t.test('trustedSpecName unwraps npm: aliases to the resolved package', t => { + t.equal(trustedSpecName(npa.resolve('lodash', '^4.17.0')), 'lodash', 'plain registry range') + t.equal(trustedSpecName(npa.resolve('@scope/pkg', '^1.0.0')), '@scope/pkg', 'scoped registry range') + // For an alias the fetched package is the alias target, not the alias key. + t.equal( + trustedSpecName(npa.resolve('@myorg/x', 'npm:attacker-pkg@1.0.0')), + 'attacker-pkg', + 'alias resolves to the underlying package name' + ) + t.equal(trustedSpecName(undefined), undefined, 'missing spec') + t.end() +}) + +t.test('an alias key cannot match an exclude pattern for its target', t => { + // Victim excludes their own scope; a malicious dep aliases an attacker package + // under a name in that scope (`"@myorg/x": "npm:attacker-pkg@1"`). The exempt + // decision must be keyed on the resolved target, not the alias key, so the + // age filter is NOT disabled for attacker-pkg. + const spec = npa.resolve('@myorg/x', 'npm:attacker-pkg@1.0.0') + t.equal(isReleaseAgeExcluded(spec.name, ['@myorg/*']), true, + 'the raw alias key would wrongly match') + t.equal(isReleaseAgeExcluded(trustedSpecName(spec), ['@myorg/*']), false, + 'the trusted target name does not match') + t.end() +}) + +t.end() diff --git a/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index cf5eb7694eb53..75001e57ebb86 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -340,6 +340,9 @@ const definitions = { Across sources, the standard precedence applies (cli > env > project > user > global), so a higher-priority source can always relax or override a lower-priority one. + + Packages whose names match \`min-release-age-exclude\` are exempt from + this filter. `, flatten, }), @@ -1471,6 +1474,9 @@ const definitions = { spawns a sub-process with \`--before\` while preparing a \`git:\` or \`github:\` dependency); when both apply, \`before\` wins within a single source and across sources the standard precedence rules apply. + + Packages whose names match \`min-release-age-exclude\` are exempt from + this filter. `, flatten: (key, obj, flatOptions) => { const age = obj['min-release-age'] @@ -1481,6 +1487,45 @@ const definitions = { } }, }), + 'min-release-age-exclude': new Definition('min-release-age-exclude', { + default: [], + hint: '', + type: [Array, String], + envExport: false, + description: ` + A list of package names or \`minimatch\` glob patterns that are exempt + from the \`min-release-age\` (and \`before\`) filter. A matching package + can always resolve to its newest version, even when a release-age window + is set. + + For example, to apply a release-age window to third-party dependencies + while letting internally maintained packages update immediately: + + \`\`\` + min-release-age=7 + min-release-age-exclude[]=@myorg/* + min-release-age-exclude[]=my-internal-pkg + \`\`\` + + Only the named package is exempt; its own dependencies still follow the + release-age policy unless they also match a pattern. Patterns match + against the package name, so \`@myorg/*\` matches \`@myorg/shared-utils\`. + + Excluding a package does not change which registry it is fetched from. You + should own your private scope on the public registry so that nobody else + can publish a package with the same name. + `, + flatten: (key, obj, flatOptions) => { + // The config layer always resolves this to an array (nopt and .npmrc both + // coerce `[Array, String]` to a list, default `[]`), so treat it as one. + // A single value may still pack multiple names as a comma string. + const list = obj[key] + .flatMap(v => String(v).split(',')) + .map(v => v.trim()) + .filter(Boolean) + flatOptions.minReleaseAgeExclude = [...new Set(list)] + }, + }), 'node-gyp': new Definition('node-gyp', { default: (() => { try { diff --git a/workspaces/config/tap-snapshots/test/type-description.js.test.cjs b/workspaces/config/tap-snapshots/test/type-description.js.test.cjs index c930287dd47ae..2cc8589e24c30 100644 --- a/workspaces/config/tap-snapshots/test/type-description.js.test.cjs +++ b/workspaces/config/tap-snapshots/test/type-description.js.test.cjs @@ -376,6 +376,10 @@ Object { null, "numeric value", ], + "min-release-age-exclude": Array [ + Function Array(), + Function String(), + ], "name": Array [ null, Function String(), diff --git a/workspaces/config/test/index.js b/workspaces/config/test/index.js index caf37bea68c9a..4ea3b848adbe5 100644 --- a/workspaces/config/test/index.js +++ b/workspaces/config/test/index.js @@ -2059,3 +2059,72 @@ t.test('CLI --min-release-age beats env npm_config_min_release_age', async t => 'CLI --min-release-age=3 overrides env npm_config_min_release_age=30' ) }) + +t.test('min-release-age-exclude', async t => { + t.test('defaults to an empty array', async t => { + const path = t.testdir() + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [process.execPath, __filename], + cwd: path, + definitions, + shorthands, + flatten, + }) + await config.load() + t.same(config.flat.minReleaseAgeExclude, [], 'flattens to []') + }) + + t.test('a single value flattens to a one-element array', async t => { + const path = t.testdir() + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [process.execPath, __filename, '--min-release-age-exclude=@myorg/*'], + cwd: path, + definitions, + shorthands, + flatten, + }) + await config.load() + t.same(config.flat.minReleaseAgeExclude, ['@myorg/*'], 'single pattern') + }) + + t.test('repeated flags accumulate into an array', async t => { + const path = t.testdir() + const config = new Config({ + npmPath: `${path}/npm`, + env: {}, + argv: [ + process.execPath, + __filename, + '--min-release-age-exclude=@myorg/*', + '--min-release-age-exclude=lodash', + ], + cwd: path, + definitions, + shorthands, + flatten, + }) + await config.load() + t.same(config.flat.minReleaseAgeExclude, ['@myorg/*', 'lodash'], 'two patterns') + }) + + t.test('a comma-delimited string is split, trimmed, and deduped', async t => { + const dir = t.testdir({ + '.npmrc': 'min-release-age-exclude = @myorg/* , lodash ,@myorg/*', + }) + const config = new Config({ + npmPath: __dirname, + env: { HOME: dir }, + argv: [process.execPath, __filename], + cwd: dir, + definitions, + shorthands, + flatten, + }) + await config.load() + t.same(config.flat.minReleaseAgeExclude, ['@myorg/*', 'lodash'], 'normalized list') + }) +})