From 78aa91aca8f3702853828d691381d0effb5b69d9 Mon Sep 17 00:00:00 2001
From: RahulGautamSingh <rahultesnik@gmail.com>
Date: Thu, 27 Apr 2023 10:36:07 +0530
Subject: [PATCH] feat(pnpm): get locked version from `pnpm-lock.yaml` (#21480)

Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
---
 .../lockfile-parsing/pnpm-lock.yaml           |  23 +++
 .../npm/extract/locked-versions.spec.ts       |  76 ++++++---
 .../manager/npm/extract/locked-versions.ts    |  23 ++-
 lib/modules/manager/npm/extract/pnpm.spec.ts  |  86 ++++++++++
 lib/modules/manager/npm/extract/pnpm.ts       | 156 +++++++++++++++++-
 lib/modules/manager/npm/post-update/types.ts  |   3 +-
 6 files changed, 336 insertions(+), 31 deletions(-)
 create mode 100644 lib/modules/manager/npm/__fixtures__/lockfile-parsing/pnpm-lock.yaml

diff --git a/lib/modules/manager/npm/__fixtures__/lockfile-parsing/pnpm-lock.yaml b/lib/modules/manager/npm/__fixtures__/lockfile-parsing/pnpm-lock.yaml
new file mode 100644
index 0000000000..d8e3e77aaf
--- /dev/null
+++ b/lib/modules/manager/npm/__fixtures__/lockfile-parsing/pnpm-lock.yaml
@@ -0,0 +1,23 @@
+lockfileVersion: '6.0'
+
+dependencies:
+  xmldoc:
+    specifier: 1.1.0
+    version: 1.1.0
+
+packages:
+
+  /sax@1.2.4:
+    resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==}
+    dev: false
+
+  /xmldoc@1.1.0:
+    resolution: {integrity: sha512-5CEmEtW6IeVMEHSIxchhwpwJKnpFFsCOl9J3R2trVPcMsT7loE7jwT/q1Zwzlk3MetuiyCAdpA699gq0E4fgdw==}
+    dependencies:
+      sax: 1.2.4
+    dev: false
+
+  /sux-1.2.4: #invlaid
+    resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==}
+    dev: false
+    
diff --git a/lib/modules/manager/npm/extract/locked-versions.spec.ts b/lib/modules/manager/npm/extract/locked-versions.spec.ts
index 8a9ff6b1f6..6f411397e7 100644
--- a/lib/modules/manager/npm/extract/locked-versions.spec.ts
+++ b/lib/modules/manager/npm/extract/locked-versions.spec.ts
@@ -2,13 +2,13 @@ import type { PackageFile } from '../../types';
 import type { NpmManagerData } from '../types';
 import { getLockedVersions } from './locked-versions';
 
-/** @type any */
 const npm = require('./npm');
-/** @type any */
+const pnpm = require('./pnpm');
 const yarn = require('./yarn');
 
 jest.mock('./npm');
 jest.mock('./yarn');
+jest.mock('./pnpm');
 
 describe('modules/manager/npm/extract/locked-versions', () => {
   describe('.getLockedVersions()', () => {
@@ -17,7 +17,10 @@ describe('modules/manager/npm/extract/locked-versions', () => {
     ): PackageFile<NpmManagerData>[] {
       return [
         {
-          managerData: { npmLock: 'package-lock.json', yarnLock: 'yarn.lock' },
+          managerData: {
+            npmLock: 'package-lock.json',
+            yarnLock: 'yarn.lock',
+          },
           extractedConstraints: {},
           deps: [
             { depName: 'a', currentValue: '1.0.0' },
@@ -485,32 +488,51 @@ describe('modules/manager/npm/extract/locked-versions', () => {
         },
       ]);
     });
+  });
 
-    it('ignores pnpm', async () => {
-      const packageFiles = [
-        {
-          managerData: {
-            pnpmShrinkwrap: 'pnpm-lock.yaml',
-          },
-          deps: [
-            { depName: 'a', currentValue: '1.0.0' },
-            { depName: 'b', currentValue: '2.0.0' },
-          ],
-          packageFile: 'some-file',
+  it('uses pnpm-lock', async () => {
+    pnpm.getPnpmLock.mockReturnValue({
+      lockedVersions: {
+        a: '1.0.0',
+        b: '2.0.0',
+        c: '3.0.0',
+      },
+      lockfileVersion: 6.0,
+    });
+    const packageFiles = [
+      {
+        managerData: {
+          pnpmShrinkwrap: 'pnpm-lock.yaml',
         },
-      ];
-      await getLockedVersions(packageFiles);
-      expect(packageFiles).toEqual([
-        {
-          deps: [
-            { currentValue: '1.0.0', depName: 'a' },
-            { currentValue: '2.0.0', depName: 'b' },
-          ],
-          lockFiles: ['pnpm-lock.yaml'],
-          managerData: { pnpmShrinkwrap: 'pnpm-lock.yaml' },
-          packageFile: 'some-file',
+        extractedConstraints: {
+          pnpm: '>=6.0.0',
         },
-      ]);
-    });
+        deps: [
+          {
+            depName: 'a',
+            currentValue: '1.0.0',
+          },
+          {
+            depName: 'b',
+            currentValue: '2.0.0',
+          },
+        ],
+        packageFile: 'some-file',
+      },
+    ];
+    pnpm.getConstraints.mockReturnValue('>=6.0.0 >=8');
+    await getLockedVersions(packageFiles);
+    expect(packageFiles).toEqual([
+      {
+        extractedConstraints: { pnpm: '>=6.0.0 >=8' },
+        deps: [
+          { currentValue: '1.0.0', depName: 'a', lockedVersion: '1.0.0' },
+          { currentValue: '2.0.0', depName: 'b', lockedVersion: '2.0.0' },
+        ],
+        lockFiles: ['pnpm-lock.yaml'],
+        managerData: { pnpmShrinkwrap: 'pnpm-lock.yaml' },
+        packageFile: 'some-file',
+      },
+    ]);
   });
 });
diff --git a/lib/modules/manager/npm/extract/locked-versions.ts b/lib/modules/manager/npm/extract/locked-versions.ts
index 6c3fa3e7a4..dde7884b90 100644
--- a/lib/modules/manager/npm/extract/locked-versions.ts
+++ b/lib/modules/manager/npm/extract/locked-versions.ts
@@ -3,6 +3,7 @@ import { logger } from '../../../../logger';
 import type { PackageFile } from '../../types';
 import type { NpmManagerData } from '../types';
 import { getNpmLock } from './npm';
+import { getConstraints, getPnpmLock } from './pnpm';
 import type { LockFile } from './types';
 import { getYarnLock } from './yarn';
 
@@ -19,7 +20,7 @@ export async function getLockedVersions(
       logger.trace('Found yarnLock');
       lockFiles.push(yarnLock);
       if (!lockFileCache[yarnLock]) {
-        logger.trace('Retrieving/parsing ' + yarnLock);
+        logger.trace(`Retrieving/parsing ${yarnLock}`);
         lockFileCache[yarnLock] = await getYarnLock(yarnLock);
       }
       const { lockfileVersion, isYarn1 } = lockFileCache[yarnLock];
@@ -87,8 +88,26 @@ export async function getLockedVersions(
         )!;
       }
     } else if (pnpmShrinkwrap) {
-      logger.debug('TODO: implement pnpm-lock.yaml parsing of lockVersion');
+      logger.debug('Found pnpm lock-file');
       lockFiles.push(pnpmShrinkwrap);
+      if (!lockFileCache[pnpmShrinkwrap]) {
+        logger.trace(`Retrieving/parsing ${pnpmShrinkwrap}`);
+        lockFileCache[pnpmShrinkwrap] = await getPnpmLock(pnpmShrinkwrap);
+      }
+      const { lockfileVersion } = lockFileCache[pnpmShrinkwrap];
+      if (lockfileVersion) {
+        packageFile.extractedConstraints!.pnpm = getConstraints(
+          lockfileVersion,
+          packageFile.extractedConstraints!.pnpm
+        );
+      }
+
+      for (const dep of packageFile.deps) {
+        // TODO: types (#7154)
+        dep.lockedVersion = semver.valid(
+          lockFileCache[pnpmShrinkwrap].lockedVersions[dep.depName!]
+        )!;
+      }
     }
     if (lockFiles.length) {
       packageFile.lockFiles = lockFiles;
diff --git a/lib/modules/manager/npm/extract/pnpm.spec.ts b/lib/modules/manager/npm/extract/pnpm.spec.ts
index af0a9fef89..6174595759 100644
--- a/lib/modules/manager/npm/extract/pnpm.spec.ts
+++ b/lib/modules/manager/npm/extract/pnpm.spec.ts
@@ -1,4 +1,5 @@
 import yaml from 'js-yaml';
+import { Fixtures } from '../../../../../test/fixtures';
 import { getFixturePath, logger } from '../../../../../test/util';
 import { GlobalConfig } from '../../../../config/global';
 import * as fs from '../../../../util/fs';
@@ -6,6 +7,8 @@ import {
   detectPnpmWorkspaces,
   extractPnpmFilters,
   findPnpmWorkspace,
+  getConstraints,
+  getPnpmLock,
 } from './pnpm';
 
 describe('modules/manager/npm/extract/pnpm', () => {
@@ -227,4 +230,87 @@ describe('modules/manager/npm/extract/pnpm', () => {
       ).toBeUndefined();
     });
   });
+
+  describe('getConstraints()', () => {
+    // no constraints
+    it.each([
+      [6.0, undefined, '>=8'],
+      [5.4, undefined, '>=7 <8'],
+      [5.3, undefined, '>=6 <7'],
+      [5.2, undefined, '>=5.10.0 <6'],
+      [5.1, undefined, '>=3.5.0 <5.9.3'],
+      [5.0, undefined, '>=3 <3.5.0'],
+    ])('adds constraints for %f', (lockfileVersion, constraints, expected) => {
+      expect(getConstraints(lockfileVersion, constraints)).toBe(expected);
+    });
+
+    // constraints present
+    it.each([
+      [6.0, '>=8.2.0', '>=8.2.0'],
+      [6.0, '>=7', '>=7 >=8'],
+
+      [5.4, '^7.2.0', '^7.2.0'],
+      [5.4, '<7.2.0', '<7.2.0 >=7'],
+      [5.4, '>7.2.0', '>7.2.0 <8'],
+      [5.4, '>=6', '>=6 >=7 <8'],
+
+      [5.3, '^6.0.0', '^6.0.0'],
+      [5.3, '<6.2.0', '<6.2.0 >=6'],
+      [5.3, '>6.2.0', '>6.2.0 <7'],
+      [5.3, '>=5', '>=5 >=6 <7'],
+
+      [5.2, '5.10.0', '5.10.0'],
+      [5.2, '>5.0.0 <5.18.0', '>5.0.0 <5.18.0 >=5.10.0'],
+      [5.2, '>5.10.0', '>5.10.0 <6'],
+      [5.2, '>=5', '>=5 >=5.10.0 <6'],
+
+      [5.1, '^4.0.0', '^4.0.0'],
+      [5.1, '<4', '<4 >=3.5.0'],
+      [5.1, '>=4', '>=4 <5.9.3'],
+      [5.1, '>=3', '>=3 >=3.5.0 <5.9.3'],
+
+      [5.0, '3.1.0', '3.1.0'],
+      [5.0, '^3.0.0', '^3.0.0 <3.5.0'],
+      [5.0, '>=3', '>=3 <3.5.0'],
+      [5.0, '>=2', '>=2 >=3 <3.5.0'],
+    ])('adds constraints for %f', (lockfileVersion, constraints, expected) => {
+      expect(getConstraints(lockfileVersion, constraints)).toBe(expected);
+    });
+  });
+
+  describe('.getPnpmLock()', () => {
+    const readLocalFile = jest.spyOn(fs, 'readLocalFile');
+
+    it('returns empty if failed to parse', async () => {
+      readLocalFile.mockResolvedValueOnce(undefined as never);
+      const res = await getPnpmLock('package.json');
+      expect(Object.keys(res.lockedVersions)).toHaveLength(0);
+    });
+
+    it('extracts', async () => {
+      const plocktest1Lock = Fixtures.get('pnpm-monorepo/pnpm-lock.yaml', '..');
+      readLocalFile.mockResolvedValueOnce(plocktest1Lock);
+      const res = await getPnpmLock('package.json');
+      expect(Object.keys(res.lockedVersions)).toHaveLength(8);
+    });
+
+    it('logs when packagePath is invalid', async () => {
+      const plocktest1Lock = Fixtures.get(
+        'lockfile-parsing/pnpm-lock.yaml',
+        '..'
+      );
+      readLocalFile.mockResolvedValueOnce(plocktest1Lock);
+      const res = await getPnpmLock('package.json');
+      expect(Object.keys(res.lockedVersions)).toHaveLength(2);
+      expect(logger.logger.trace).toHaveBeenLastCalledWith(
+        'Invalid package path /sux-1.2.4'
+      );
+    });
+
+    it('returns empty if no deps', async () => {
+      readLocalFile.mockResolvedValueOnce('{}');
+      const res = await getPnpmLock('package.json');
+      expect(Object.keys(res.lockedVersions)).toHaveLength(0);
+    });
+  });
 });
diff --git a/lib/modules/manager/npm/extract/pnpm.ts b/lib/modules/manager/npm/extract/pnpm.ts
index 1f566d1e8b..18e48b8185 100644
--- a/lib/modules/manager/npm/extract/pnpm.ts
+++ b/lib/modules/manager/npm/extract/pnpm.ts
@@ -1,6 +1,7 @@
 import is from '@sindresorhus/is';
 import { findPackages } from 'find-packages';
 import { load } from 'js-yaml';
+import semver from 'semver';
 import upath from 'upath';
 import { GlobalConfig } from '../../../../config/global';
 import { logger } from '../../../../logger';
@@ -10,9 +11,15 @@ import {
   localPathExists,
   readLocalFile,
 } from '../../../../util/fs';
+import { regEx } from '../../../../util/regex';
 import type { PackageFile } from '../../types';
+import type { PnpmLockFile } from '../post-update/types';
 import type { NpmManagerData } from '../types';
-import type { PnpmWorkspaceFile } from './types';
+import type { LockFile, PnpmWorkspaceFile } from './types';
+
+function isPnpmLockfile(obj: any): obj is PnpmLockFile {
+  return is.plainObject(obj) && 'lockfileVersion' in obj;
+}
 
 export async function extractPnpmFilters(
   fileName: string
@@ -135,3 +142,150 @@ export async function detectPnpmWorkspaces(
     }
   }
 }
+
+export async function getPnpmLock(filePath: string): Promise<LockFile> {
+  try {
+    const pnpmLockRaw = await readLocalFile(filePath, 'utf8');
+    if (!pnpmLockRaw) {
+      throw new Error('Unable to read pnpm-lock.yaml');
+    }
+
+    const lockParsed = load(pnpmLockRaw);
+    if (!isPnpmLockfile(lockParsed)) {
+      throw new Error('Invalid or empty lockfile');
+    }
+    logger.trace({ lockParsed }, 'pnpm lockfile parsed');
+
+    // field lockfileVersion is type string in lockfileVersion = 6 and type number in < 6
+    const lockfileVersion: number = is.number(lockParsed.lockfileVersion)
+      ? lockParsed.lockfileVersion
+      : parseFloat(lockParsed.lockfileVersion);
+
+    const lockedVersions: Record<string, string> = {};
+    const packagePathRegex = regEx(
+      /^\/(?<packageName>.+)(?:@|\/)(?<version>[^/@]+)$/
+    ); // eg. "/<packageName>(@|/)<version>"
+
+    for (const packagePath of Object.keys(lockParsed.packages ?? {})) {
+      const result = packagePath.match(packagePathRegex);
+      if (!result?.groups) {
+        logger.trace(`Invalid package path ${packagePath}`);
+        continue;
+      }
+
+      const packageName = result.groups.packageName;
+      const version = result.groups.version;
+      logger.trace({
+        packagePath,
+        packageName,
+        version,
+      });
+      lockedVersions[packageName] = version;
+    }
+    return {
+      lockedVersions,
+      lockfileVersion,
+    };
+  } catch (err) {
+    logger.debug({ filePath, err }, 'Warning: Exception parsing pnpm lockfile');
+    return { lockedVersions: {} };
+  }
+}
+
+export function getConstraints(
+  lockfileVersion: number,
+  constraints?: string
+): string {
+  let newConstraints = constraints;
+
+  // find matching lockfileVersion and use its constraints
+  // if no match found use lockfileVersion 5
+  // lockfileVersion 5 is the minimum version required to generate the pnpm-lock.yaml file
+  const { lowerBound, upperBound, lowerConstraint, upperConstraint } =
+    lockToPnpmVersionMapping.find(
+      (m) => m.lockfileVersion === lockfileVersion
+    ) ?? {
+      lockfileVersion: 5.0,
+      lowerBound: '2.24.0',
+      upperBound: '3.5.0',
+      lowerConstraint: '>=3',
+      upperConstraint: '<3.5.0',
+    };
+
+  // inorder to ensure that the constraint doesn't allow any pnpm versions that can't generate the extracted lockfileVersion
+  // compare the current constraint to the lowerBound and upperBound of the lockfileVersion
+  // if the current constraint is not comaptible, add the lowerConstraint and upperConstraint, whichever is needed
+  if (newConstraints) {
+    // if constraint satisfies versions lower than lowerBound add the lowerConstraint to narrow the range
+    if (semver.satisfies(lowerBound, newConstraints)) {
+      newConstraints += ` ${lowerConstraint}`;
+    }
+
+    // if constraint satisfies versions higher than upperBound add the upperConstraint to narrow the range
+    if (
+      upperBound &&
+      upperConstraint &&
+      semver.satisfies(upperBound, newConstraints)
+    ) {
+      newConstraints += ` ${upperConstraint}`;
+    }
+  }
+  // if no constraint is present, add the lowerConstraint and upperConstraint corresponding to the lockfileVersion
+  else {
+    newConstraints = `${lowerConstraint}${
+      upperConstraint ? ` ${upperConstraint}` : ''
+    }`;
+  }
+
+  return newConstraints;
+}
+
+/**
+ pnpm lockfiles have corresponding version numbers called "lockfileVersion"
+ each lockfileVersion can only be generated by a certain pnpm version ranges
+ eg. lockfileVersion: 5.4 can only be generated by pnpm version >=7 && <8
+ official list can be found here : https:github.com/pnpm/spec/tree/master/lockfile
+ we use the mapping present below to find the compatible pnpm version range for a given lockfileVersion
+
+ the various terms used in the mapping are explained below:
+ lowerConstriant : lowest pnpm version that can generate the lockfileVersion
+ upperConstraint : highest pnpm version that can generate the lockfileVersion
+ lowerBound      : highest pnpm version that is less than the lowerConstraint
+ upperBound      : lowest pnpm version that is greater than upperConstraint
+
+ For handling future lockfileVersions, we need to:
+ 1. add a upperBound and upperConstraint to the current lastest lockfileVersion
+ 2. add an object for the new lockfileVersion with lowerBound and lowerConstraint
+ */
+
+const lockToPnpmVersionMapping = [
+  { lockfileVersion: 6.0, lowerBound: '7.32.0', lowerConstraint: '>=8' },
+  {
+    lockfileVersion: 5.4,
+    lowerBound: '6.35.1',
+    upperBound: '8.0.0',
+    lowerConstraint: '>=7',
+    upperConstraint: '<8',
+  },
+  {
+    lockfileVersion: 5.3,
+    lowerBound: '5.18.10',
+    upperBound: '7.0.0',
+    lowerConstraint: '>=6',
+    upperConstraint: '<7',
+  },
+  {
+    lockfileVersion: 5.2,
+    lowerBound: '5.9.3',
+    upperBound: '5.18.10',
+    lowerConstraint: '>=5.10.0',
+    upperConstraint: '<6',
+  },
+  {
+    lockfileVersion: 5.1,
+    lowerBound: '3.4.1',
+    upperBound: '5.9.3',
+    lowerConstraint: '>=3.5.0',
+    upperConstraint: '<5.9.3',
+  },
+];
diff --git a/lib/modules/manager/npm/post-update/types.ts b/lib/modules/manager/npm/post-update/types.ts
index 56b50b78cc..a134a951e7 100644
--- a/lib/modules/manager/npm/post-update/types.ts
+++ b/lib/modules/manager/npm/post-update/types.ts
@@ -31,7 +31,8 @@ export interface GenerateLockFileResult {
 }
 
 export interface PnpmLockFile {
-  lockfileVersion?: number;
+  lockfileVersion: number | string;
+  packages?: Record<string, unknown>;
 }
 
 export interface YarnRcYmlFile {
-- 
GitLab