fix(core): handle external node without default version when generating a pnpm pruned lockfile (#31503)

## Current Behavior

When generating a pruned pnpm lockfile, if there's no external node with
a default version for a given package and the dependency specification
for the package includes a Semver range specifier, an error is thrown.

## Expected Behavior

When generating a pruned pnpm lockfile, the parser should correctly
handle the scenario where there's no external node with a default
version for a given package, and the dependency specification for the
package includes a Semver range specifier.

## Related Issue(s)

Fixes #28627
This commit is contained in:
Leosvel Pérez Espinosa 2025-06-09 15:31:33 +02:00 committed by GitHub
parent f1c090b640
commit e68d884d63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 9073 additions and 4 deletions

View File

@ -0,0 +1,10 @@
{
"name": "app",
"version": "0.0.1",
"private": true,
"dependencies": {
"tmp": "^0.2.3",
"lodash": "^4.17.0",
"semver": "^7.3.0"
}
}

View File

@ -0,0 +1,17 @@
{
"name": "test",
"version": "0.0.0",
"license": "MIT",
"scripts": {},
"private": true,
"devDependencies": {
"@nx/js": "21.1.2",
"@nx/webpack": "^21.1.2",
"@swc-node/register": "~1.9.1",
"@swc/core": "~1.5.7",
"@swc/helpers": "~0.5.11",
"@types/node": "18.16.9",
"nx": "21.1.2",
"typescript": "~5.7.2"
}
}

View File

@ -0,0 +1,42 @@
export default `lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
lodash:
specifier: ^4.17.0
version: 4.17.21
semver:
specifier: ^7.3.0
version: 7.7.2
tmp:
specifier: ^0.2.3
version: 0.2.3
packages:
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
semver@7.7.2:
resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
engines: {node: '>=10'}
hasBin: true
tmp@0.2.3:
resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==}
engines: {node: '>=14.14'}
snapshots:
lodash@4.17.21: {}
semver@7.7.2: {}
tmp@0.2.3: {}
`;

View File

@ -1583,4 +1583,69 @@ describe('pnpm LockFile utility', () => {
expect(result).toEqual(prunedLockFile); expect(result).toEqual(prunedLockFile);
}); });
}); });
describe('pnpm semver range specifier', () => {
beforeEach(() => {
const fileSys = {
'node_modules/.modules.yaml': require(joinPathFragments(
__dirname,
'__fixtures__/pnpm-semver-range-specifier/.modules.yaml'
)).default,
};
vol.fromJSON(fileSys, '/root');
});
it('should correctly prune the lock file', () => {
const lockFile = require(joinPathFragments(
__dirname,
'__fixtures__/pnpm-semver-range-specifier/pnpm-lock.yaml'
)).default;
const expectedPrunedLockFile = require(joinPathFragments(
__dirname,
'__fixtures__/pnpm-semver-range-specifier/pruned-pnpm-lock.yaml'
)).default;
const packageJson = require(joinPathFragments(
__dirname,
'__fixtures__/pnpm-semver-range-specifier/app/package.json'
));
let graph: ProjectGraph = {
nodes: {},
dependencies: {},
externalNodes: {
'npm:lodash': {
type: 'npm',
name: 'npm:lodash',
data: { version: '4.17.21', packageName: 'lodash' },
},
'npm:semver@5.7.2': {
type: 'npm',
name: 'npm:semver@5.7.2',
data: { version: '5.7.2', packageName: 'semver' },
},
'npm:semver@6.3.1': {
type: 'npm',
name: 'npm:semver@6.3.1',
data: { version: '6.3.1', packageName: 'semver' },
},
'npm:semver@7.7.2': {
type: 'npm',
name: 'npm:semver@7.7.2',
data: { version: '7.7.2', packageName: 'semver' },
},
'npm:tmp': {
type: 'npm',
name: 'npm:tmp',
data: { version: '0.2.3', packageName: 'tmp' },
},
},
};
const prunedGraph = pruneProjectGraph(graph, packageJson);
const result = stringifyPnpmLockfile(prunedGraph, lockFile, packageJson);
expect(result).toEqual(expectedPrunedLockFile);
});
});
}); });

View File

@ -26,6 +26,7 @@ import {
} from '../../../config/project-graph'; } from '../../../config/project-graph';
import { hashArray } from '../../../hasher/file-hasher'; import { hashArray } from '../../../hasher/file-hasher';
import { CreateDependenciesContext } from '../../../project-graph/plugins'; import { CreateDependenciesContext } from '../../../project-graph/plugins';
import { findNodeMatchingVersion } from './project-graph-pruning';
// we use key => node map to avoid duplicate work when parsing keys // we use key => node map to avoid duplicate work when parsing keys
let keyMap = new Map<string, Set<ProjectGraphExternalNode>>(); let keyMap = new Map<string, Set<ProjectGraphExternalNode>>();
@ -407,7 +408,7 @@ export function stringifyPnpmLockfile(
const rootSnapshot = mapRootSnapshot( const rootSnapshot = mapRootSnapshot(
packageJson, packageJson,
packages, packages,
graph.externalNodes, graph,
+lockfileVersion +lockfileVersion
); );
const snapshots = mapSnapshots( const snapshots = mapSnapshots(
@ -567,7 +568,7 @@ function versionIsAlias(
function mapRootSnapshot( function mapRootSnapshot(
packageJson: NormalizedPackageJson, packageJson: NormalizedPackageJson,
packages: PackageSnapshots, packages: PackageSnapshots,
nodes: Record<string, ProjectGraphExternalNode>, graph: ProjectGraph,
lockfileVersion: number lockfileVersion: number
): ProjectSnapshot { ): ProjectSnapshot {
const snapshot: ProjectSnapshot = { specifiers: {} }; const snapshot: ProjectSnapshot = { specifiers: {} };
@ -581,7 +582,16 @@ function mapRootSnapshot(
Object.keys(packageJson[depType]).forEach((packageName) => { Object.keys(packageJson[depType]).forEach((packageName) => {
const version = packageJson[depType][packageName]; const version = packageJson[depType][packageName];
const node = const node =
nodes[`npm:${packageName}@${version}`] || nodes[`npm:${packageName}`]; graph.externalNodes[`npm:${packageName}@${version}`] ||
(graph.externalNodes[`npm:${packageName}`] &&
graph.externalNodes[`npm:${packageName}`].data.version === version
? graph.externalNodes[`npm:${packageName}`]
: findNodeMatchingVersion(graph, packageName, version));
if (!node) {
throw new Error(
`Could not find external node for package ${packageName}@${version}.`
);
}
snapshot.specifiers[packageName] = version; snapshot.specifiers[packageName] = version;
// peer dependencies are mapped to dependencies // peer dependencies are mapped to dependencies
let section = depType === 'peerDependencies' ? 'dependencies' : depType; let section = depType === 'peerDependencies' ? 'dependencies' : depType;

View File

@ -69,7 +69,7 @@ function normalizeDependencies(packageJson: PackageJson, graph: ProjectGraph) {
return combinedDependencies; return combinedDependencies;
} }
function findNodeMatchingVersion( export function findNodeMatchingVersion(
graph: ProjectGraph, graph: ProjectGraph,
packageName: string, packageName: string,
versionExpr: string versionExpr: string
@ -146,6 +146,11 @@ function rehoistNodes(
} }
} }
}); });
if (!packagesToRehoist.size) {
return;
}
// invert dependencies for easier traversal back // invert dependencies for easier traversal back
const invertedGraph = reverse(builder.graph); const invertedGraph = reverse(builder.graph);
const invBuilder = new ProjectGraphBuilder(invertedGraph, {}); const invBuilder = new ProjectGraphBuilder(invertedGraph, {});