From 6fef1d1650ec936321dc2819d409d604c7415fab Mon Sep 17 00:00:00 2001
From: Gabriel-Ladzaretti
 <97394622+Gabriel-Ladzaretti@users.noreply.github.com>
Date: Fri, 14 Oct 2022 12:26:20 +0300
Subject: [PATCH] feat(manager/npm): add support for x-range "all" - `"*"`
 range (#18251)

---
 .../extract/__snapshots__/index.spec.ts.snap  |  1 -
 lib/modules/manager/npm/extract/index.spec.ts |  2 +-
 lib/modules/manager/npm/extract/index.ts      |  3 -
 lib/modules/versioning/npm/index.spec.ts      | 28 ++++++-
 lib/modules/versioning/npm/range.ts           |  4 +
 lib/modules/versioning/semver/common.spec.ts  | 16 ++++
 lib/modules/versioning/semver/common.ts       | 10 +++
 .../repository/process/lookup/index.spec.ts   | 82 +++++++++++++++++++
 8 files changed, 140 insertions(+), 6 deletions(-)
 create mode 100644 lib/modules/versioning/semver/common.spec.ts
 create mode 100644 lib/modules/versioning/semver/common.ts

diff --git a/lib/modules/manager/npm/extract/__snapshots__/index.spec.ts.snap b/lib/modules/manager/npm/extract/__snapshots__/index.spec.ts.snap
index f9808f6b9d..165138b1c3 100644
--- a/lib/modules/manager/npm/extract/__snapshots__/index.spec.ts.snap
+++ b/lib/modules/manager/npm/extract/__snapshots__/index.spec.ts.snap
@@ -59,7 +59,6 @@ exports[`modules/manager/npm/extract/index .extractPackageFile() extracts engine
       "depName": "foo",
       "depType": "devDependencies",
       "prettyDepType": "devDependency",
-      "skipReason": "any-version",
     },
     {
       "currentValue": "file:../foo/bar",
diff --git a/lib/modules/manager/npm/extract/index.spec.ts b/lib/modules/manager/npm/extract/index.spec.ts
index 98ff5aac49..bf5095ee46 100644
--- a/lib/modules/manager/npm/extract/index.spec.ts
+++ b/lib/modules/manager/npm/extract/index.spec.ts
@@ -353,7 +353,7 @@ describe('modules/manager/npm/extract/index', () => {
         deps: [
           { depName: 'angular', currentValue: '1.6.0' },
           { depName: '@angular/cli', currentValue: '1.6.0' },
-          { depName: 'foo', currentValue: '*', skipReason: 'any-version' },
+          { depName: 'foo', currentValue: '*' },
           {
             depName: 'bar',
             currentValue: 'file:../foo/bar',
diff --git a/lib/modules/manager/npm/extract/index.ts b/lib/modules/manager/npm/extract/index.ts
index ac2947c6ed..d395a4940d 100644
--- a/lib/modules/manager/npm/extract/index.ts
+++ b/lib/modules/manager/npm/extract/index.ts
@@ -271,9 +271,6 @@ export async function extractPackageFile(
     }
     if (isValid(dep.currentValue)) {
       dep.datasource = NpmDatasource.id;
-      if (dep.currentValue === '*') {
-        dep.skipReason = 'any-version';
-      }
       if (dep.currentValue === '') {
         dep.skipReason = 'empty';
       }
diff --git a/lib/modules/versioning/npm/index.spec.ts b/lib/modules/versioning/npm/index.spec.ts
index c56323ab65..7b70d5eee3 100644
--- a/lib/modules/versioning/npm/index.spec.ts
+++ b/lib/modules/versioning/npm/index.spec.ts
@@ -5,19 +5,44 @@ describe('modules/versioning/npm/index', () => {
     version                                          | isValid
     ${'17.04.0'}                                     | ${false}
     ${'1.2.3'}                                       | ${true}
+    ${'*'}                                           | ${true}
+    ${'x'}                                           | ${true}
+    ${'X'}                                           | ${true}
+    ${'1'}                                           | ${true}
     ${'1.2.3-foo'}                                   | ${true}
     ${'1.2.3foo'}                                    | ${false}
     ${'~1.2.3'}                                      | ${true}
+    ${'1.2'}                                         | ${true}
+    ${'1.2.x'}                                       | ${true}
+    ${'1.2.X'}                                       | ${true}
+    ${'1.2.*'}                                       | ${true}
+    ${'~1.2.3'}                                      | ${true}
     ${'^1.2.3'}                                      | ${true}
     ${'>1.2.3'}                                      | ${true}
     ${'renovatebot/renovate'}                        | ${false}
     ${'renovatebot/renovate#main'}                   | ${false}
     ${'https://github.com/renovatebot/renovate.git'} | ${false}
   `('isValid("$version") === $isValid', ({ version, isValid }) => {
-    const res = !!semver.isValid(version);
+    const res = semver.isValid(version);
     expect(res).toBe(isValid);
   });
 
+  test.each`
+    versions                                          | range      | maxSatisfying
+    ${['2.3.3.', '2.3.4', '2.4.5', '2.5.1', '3.0.0']} | ${'*'}     | ${'3.0.0'}
+    ${['2.3.3.', '2.3.4', '2.4.5', '2.5.1', '3.0.0']} | ${'x'}     | ${'3.0.0'}
+    ${['2.3.3.', '2.3.4', '2.4.5', '2.5.1', '3.0.0']} | ${'X'}     | ${'3.0.0'}
+    ${['2.3.3.', '2.3.4', '2.4.5', '2.5.1', '3.0.0']} | ${'2'}     | ${'2.5.1'}
+    ${['2.3.3.', '2.3.4', '2.4.5', '2.5.1', '3.0.0']} | ${'2.*'}   | ${'2.5.1'}
+    ${['2.3.3.', '2.3.4', '2.4.5', '2.5.1', '3.0.0']} | ${'2.3'}   | ${'2.3.4'}
+    ${['2.3.3.', '2.3.4', '2.4.5', '2.5.1', '3.0.0']} | ${'2.3.*'} | ${'2.3.4'}
+  `(
+    'getSatisfyingVersion("$versions","$range") === $maxSatisfying',
+    ({ versions, range, maxSatisfying }) => {
+      expect(semver.getSatisfyingVersion(versions, range)).toBe(maxSatisfying);
+    }
+  );
+
   test.each`
     version            | isSingle
     ${'1.2.3'}         | ${true}
@@ -59,6 +84,7 @@ describe('modules/versioning/npm/index', () => {
     ${'>= 0.0.1 < 0.0.4'}   | ${'bump'}     | ${'0.0.4'}     | ${'0.0.5'}              | ${'>= 0.0.5 < 0.0.6'}
     ${'>= 0.0.1 < 1'}       | ${'bump'}     | ${'1.0.0'}     | ${'1.0.1'}              | ${'>= 1.0.1 < 2'}
     ${'>= 0.0.1 < 1'}       | ${'bump'}     | ${'1.0.0'}     | ${'1.0.1'}              | ${'>= 1.0.1 < 2'}
+    ${'*'}                  | ${'bump'}     | ${'1.0.0'}     | ${'1.0.1'}              | ${null}
     ${'<=1.2.3'}            | ${'widen'}    | ${'1.0.0'}     | ${'1.2.3'}              | ${'<=1.2.3'}
     ${'<=1.2.3'}            | ${'widen'}    | ${'1.0.0'}     | ${'1.2.4'}              | ${'<=1.2.4'}
     ${'>=1.2.3'}            | ${'widen'}    | ${'1.0.0'}     | ${'1.2.3'}              | ${'>=1.2.3'}
diff --git a/lib/modules/versioning/npm/range.ts b/lib/modules/versioning/npm/range.ts
index 9e2458c8e4..8ddfad58d3 100644
--- a/lib/modules/versioning/npm/range.ts
+++ b/lib/modules/versioning/npm/range.ts
@@ -3,6 +3,7 @@ import semver from 'semver';
 import semverUtils from 'semver-utils';
 import { logger } from '../../../logger';
 import { regEx } from '../../../util/regex';
+import { isSemVerXRange } from '../semver/common';
 import type { NewValueConfig } from '../types';
 
 const {
@@ -63,6 +64,9 @@ export function getNewValue({
   currentVersion,
   newVersion,
 }: NewValueConfig): string | null {
+  if (rangeStrategy === 'bump' && isSemVerXRange(currentValue)) {
+    return null;
+  }
   if (rangeStrategy === 'pin' || isVersion(currentValue)) {
     return newVersion;
   }
diff --git a/lib/modules/versioning/semver/common.spec.ts b/lib/modules/versioning/semver/common.spec.ts
new file mode 100644
index 0000000000..372ede4bdd
--- /dev/null
+++ b/lib/modules/versioning/semver/common.spec.ts
@@ -0,0 +1,16 @@
+import { isSemVerXRange } from './common';
+
+describe('modules/versioning/semver/common', () => {
+  test.each`
+    range      | expected
+    ${'*'}     | ${true}
+    ${'x'}     | ${true}
+    ${'X'}     | ${true}
+    ${''}      | ${true}
+    ${'1'}     | ${false}
+    ${'1.2'}   | ${false}
+    ${'1.2.3'} | ${false}
+  `('isSemVerXRange("range") === $expected', ({ range, expected }) => {
+    expect(isSemVerXRange(range)).toBe(expected);
+  });
+});
diff --git a/lib/modules/versioning/semver/common.ts b/lib/modules/versioning/semver/common.ts
new file mode 100644
index 0000000000..58f1230a01
--- /dev/null
+++ b/lib/modules/versioning/semver/common.ts
@@ -0,0 +1,10 @@
+const SEMVER_X_RANGE = ['*', 'x', 'X', ''] as const;
+type SemVerXRangeArray = typeof SEMVER_X_RANGE;
+export type SemVerXRange = SemVerXRangeArray[number];
+
+/**
+ * https://docs.npmjs.com/cli/v6/using-npm/semver#x-ranges-12x-1x-12-
+ */
+export function isSemVerXRange(range: string): range is SemVerXRange {
+  return SEMVER_X_RANGE.includes(range as SemVerXRange);
+}
diff --git a/lib/workers/repository/process/lookup/index.spec.ts b/lib/workers/repository/process/lookup/index.spec.ts
index f89a35406d..5a0dd27cab 100644
--- a/lib/workers/repository/process/lookup/index.spec.ts
+++ b/lib/workers/repository/process/lookup/index.spec.ts
@@ -334,6 +334,88 @@ describe('workers/repository/process/lookup/index', () => {
       ]);
     });
 
+    it.each`
+      strategy             | updates
+      ${'update-lockfile'} | ${[{ isLockfileUpdate: true, newValue: '*', newVersion: '0.9.7', updateType: 'minor' }, { isLockfileUpdate: true, newValue: '*', newVersion: '1.4.1', updateType: 'major' }]}
+      ${'pin'}             | ${[{ newValue: '0.4.0', updateType: 'pin' }, { newValue: '0.9.7', updateType: 'minor' }, { newValue: '1.4.1', updateType: 'major' }]}
+    `(
+      'supports for x-range-all for replaceStrategy = $strategy (with lockfile)',
+      async ({ strategy, updates }) => {
+        config.currentValue = '*';
+        config.rangeStrategy = strategy;
+        config.lockedVersion = '0.4.0';
+        config.depName = 'q';
+        config.datasource = NpmDatasource.id;
+        httpMock
+          .scope('https://registry.npmjs.org')
+          .get('/q')
+          .reply(200, qJson);
+        expect(await lookup.lookupUpdates(config)).toMatchObject({ updates });
+      }
+    );
+
+    it.each`
+      strategy
+      ${'widen'}
+      ${'bump'}
+      ${'replace'}
+    `(
+      'doesnt offer updates for x-range-all (with lockfile) when replaceStrategy = $strategy',
+      async ({ strategy }) => {
+        config.currentValue = 'x';
+        config.rangeStrategy = strategy;
+        config.lockedVersion = '0.4.0';
+        config.depName = 'q';
+        config.datasource = NpmDatasource.id;
+        httpMock
+          .scope('https://registry.npmjs.org')
+          .get('/q')
+          .reply(200, qJson);
+        expect((await lookup.lookupUpdates(config)).updates).toEqual([]);
+      }
+    );
+
+    it('supports pinning for x-range-all (no lockfile)', async () => {
+      config.currentValue = '*';
+      config.rangeStrategy = 'pin';
+      config.depName = 'q';
+      config.datasource = NpmDatasource.id;
+      httpMock.scope('https://registry.npmjs.org').get('/q').reply(200, qJson);
+      expect(await lookup.lookupUpdates(config)).toMatchObject({
+        updates: [{ newValue: '1.4.1', updateType: 'pin' }],
+      });
+    });
+
+    it('covers pinning an unsupported x-range-all value', async () => {
+      config.currentValue = '';
+      config.rangeStrategy = 'pin';
+      config.depName = 'q';
+      config.datasource = NpmDatasource.id;
+      httpMock.scope('https://registry.npmjs.org').get('/q').reply(200, qJson);
+      expect((await lookup.lookupUpdates(config)).updates).toEqual([]);
+    });
+
+    it.each`
+      strategy
+      ${'widen'}
+      ${'bump'}
+      ${'update-lockfile'}
+      ${'replace'}
+    `(
+      'doesnt offer updates for x-range-all (no lockfile) when replaceStrategy = $strategy',
+      async ({ strategy }) => {
+        config.currentValue = 'X';
+        config.rangeStrategy = strategy;
+        config.depName = 'q';
+        config.datasource = NpmDatasource.id;
+        httpMock
+          .scope('https://registry.npmjs.org')
+          .get('/q')
+          .reply(200, qJson);
+        expect((await lookup.lookupUpdates(config)).updates).toEqual([]);
+      }
+    );
+
     it('ignores pinning for ranges when other upgrade exists', async () => {
       config.currentValue = '~0.9.0';
       config.rangeStrategy = 'pin';
-- 
GitLab