nx
Version:
1,048 lines • 50.2 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getPnpmLockfileNodes = getPnpmLockfileNodes;
exports.getPnpmLockfileDependencies = getPnpmLockfileDependencies;
exports.stringifyPnpmLockfile = stringifyPnpmLockfile;
const pnpm_normalizer_1 = require("./utils/pnpm-normalizer");
const package_json_1 = require("./utils/package-json");
const object_sort_1 = require("../../../utils/object-sort");
const project_graph_builder_1 = require("../../../project-graph/project-graph-builder");
const project_graph_1 = require("../../../config/project-graph");
const file_hasher_1 = require("../../../hasher/file-hasher");
const catalog_1 = require("../../../utils/catalog");
const pruned_output_1 = require("./pruned-output");
const project_graph_pruning_1 = require("./project-graph-pruning");
const path_1 = require("path");
const workspace_root_1 = require("../../../utils/workspace-root");
const node_fs_1 = require("node:fs");
const logger_1 = require("../../../utils/logger");
const get_workspace_packages_from_graph_1 = require("../utils/get-workspace-packages-from-graph");
const semver_1 = require("semver");
// The dep types walked when pulling a copied module's own workspace deps into
// the pruned lockfile: production sections only, since a dependency's
// devDependencies are never installed. The root importer is different: it
// mirrors every manifest section, devDependencies included (see mapRootSnapshot).
const WORKSPACE_DEP_TYPES = ['dependencies', 'optionalDependencies'];
let currentLockFileHash;
let parsedLockFile;
function parsePnpmLockFile(lockFileContent, lockFileHash) {
if (lockFileHash === currentLockFileHash) {
return parsedLockFile;
}
const results = (0, pnpm_normalizer_1.parseAndNormalizePnpmLockfile)(lockFileContent);
parsedLockFile = results;
currentLockFileHash = lockFileHash;
return results;
}
function getPnpmLockfileNodes(lockFileContent, lockFileHash) {
const data = parsePnpmLockFile(lockFileContent, lockFileHash);
if (+data.lockfileVersion.toString() >= 10) {
console.warn('Nx was tested only with pnpm lockfile version 5-9. If you encounter any issues, please report them and downgrade to older version of pnpm.');
}
const isV5 = (0, pnpm_normalizer_1.isV5Syntax)(data);
return getNodes(data, isV5);
}
function getPnpmLockfileDependencies(lockFileContent, lockFileHash, ctx, keyMap) {
const data = parsePnpmLockFile(lockFileContent, lockFileHash);
if (+data.lockfileVersion.toString() >= 10) {
console.warn('Nx was tested only with pnpm lockfile version 5-9. If you encounter any issues, please report them and downgrade to older version of pnpm.');
}
const isV5 = (0, pnpm_normalizer_1.isV5Syntax)(data);
return getDependencies(data, keyMap, isV5, ctx);
}
function invertRecordWithoutAliases(record) {
const result = {};
for (const [depName, depVersion] of Object.entries(record)) {
if (isAliasVersion(depVersion)) {
// Ignore alias specifiers so aliases do not replace actual package names
continue;
}
result[depVersion] = depName;
}
return result;
}
const cachedInvertedRecords = new Map();
function matchPropValue(record, key, originalPackageName, recordName) {
if (!record) {
return undefined;
}
if (!cachedInvertedRecords.has(recordName)) {
// Inversion is only for non-alias specs to avoid alias -> target mislabeling.
cachedInvertedRecords.set(recordName, invertRecordWithoutAliases(record));
}
const packageName = cachedInvertedRecords.get(recordName)[key];
if (packageName) {
return packageName;
}
// check if non-aliased name is found
if (record[originalPackageName] &&
key.startsWith(`/${originalPackageName}/${record[originalPackageName]}`)) {
return originalPackageName;
}
}
function matchedDependencyName(importer, key, originalPackageName) {
return (matchPropValue(importer.dependencies, key, originalPackageName, 'dependencies') ||
matchPropValue(importer.optionalDependencies, key, originalPackageName, 'optionalDependencies') ||
matchPropValue(importer.peerDependencies, key, originalPackageName, 'peerDependencies'));
}
function createHashFromSnapshot(snapshot, patchHash) {
const baseHash = snapshot.resolution?.['integrity'] ||
(snapshot.resolution?.['tarball']
? (0, file_hasher_1.hashArray)([snapshot.resolution['tarball']])
: undefined);
// If there's a patch hash, combine it with the base hash
if (patchHash && baseHash) {
return (0, file_hasher_1.hashArray)([baseHash, patchHash]);
}
return baseHash ?? patchHash;
}
function isAliasVersion(depVersion) {
return depVersion.startsWith('/') || depVersion.includes('@');
}
/**
* Finds the appropriate patch hash for a package based on its name and version.
* Follows PNPM's priority order (https://pnpm.io/settings#patcheddependencies):
* 1. Exact version match (e.g., "vitest@3.2.4") - highest priority
* 2. Version range match (e.g., "vitest@^3.0.0")
* 3. Name-only match (e.g., "vitest") - lowest priority
*/
function findPatchHash(patchEntriesByPackage, packageName, version) {
const entries = patchEntriesByPackage.get(packageName);
if (!entries) {
return undefined; // No patches for this package
}
// Check for exact version match first (highest priority)
const exactMatch = entries.find((entry) => entry.versionSpecifier === version);
if (exactMatch) {
return exactMatch.hash;
}
// Check for version range matches
for (const entry of entries) {
// Skip name-only entries (will be handled at the end with lowest priority)
if (entry.versionSpecifier === null) {
continue;
}
if ((0, semver_1.validRange)(entry.versionSpecifier)) {
try {
if ((0, semver_1.satisfies)(version, entry.versionSpecifier)) {
return entry.hash;
}
}
catch {
// Invalid semver range, skip
}
}
}
// Fall back to name-only match (lowest priority)
const nameOnlyMatch = entries.find((entry) => entry.versionSpecifier === null);
return nameOnlyMatch?.hash;
}
// Segment-aware: `..` and `../x` escape, a directory literally named `..cache`
// does not. Absolute targets are resolved against the workspace root.
function linkTargetEscapesWorkspace(importerPath, depVersion) {
const rawTarget = depVersion.slice('link:'.length);
if (path_1.posix.isAbsolute(rawTarget) || (0, path_1.isAbsolute)(rawTarget)) {
const rel = (0, path_1.relative)(workspace_root_1.workspaceRoot, rawTarget);
return rel === '..' || rel.startsWith(`..${path_1.sep}`) || (0, path_1.isAbsolute)(rel);
}
const combined = path_1.posix.normalize(path_1.posix.join(importerPath === '.' ? '' : importerPath, rawTarget));
return combined === '..' || combined.startsWith('../');
}
function getNodes(data, isV5) {
cachedInvertedRecords.clear();
const keyMap = new Map();
const nodes = new Map();
// Extract and pre-parse patch information from patchedDependencies section
const patchEntriesByPackage = new Map();
if (data.patchedDependencies) {
for (const specifier of Object.keys(data.patchedDependencies)) {
const patchInfo = data.patchedDependencies[specifier];
const patchHash = typeof patchInfo === 'string'
? patchInfo
: patchInfo && typeof patchInfo === 'object' && 'hash' in patchInfo
? patchInfo.hash
: undefined;
if (patchHash) {
const packageName = extractNameFromKey(specifier, false);
const versionSpecifier = getVersion(specifier, packageName) || null;
if (!patchEntriesByPackage.has(packageName)) {
patchEntriesByPackage.set(packageName, []);
}
patchEntriesByPackage.get(packageName).push({
versionSpecifier,
hash: patchHash,
});
}
}
}
const maybeAliasedPackageVersions = new Map(); // <version, alias>
if (data.importers['.'].optionalDependencies) {
for (const [depName, depVersion] of Object.entries(data.importers['.'].optionalDependencies)) {
if (isAliasVersion(depVersion)) {
maybeAliasedPackageVersions.set(depVersion, depName);
}
}
}
if (data.importers['.'].devDependencies) {
for (const [depName, depVersion] of Object.entries(data.importers['.'].devDependencies)) {
if (isAliasVersion(depVersion)) {
maybeAliasedPackageVersions.set(depVersion, depName);
}
}
}
if (data.importers['.'].dependencies) {
for (const [depName, depVersion] of Object.entries(data.importers['.'].dependencies)) {
if (isAliasVersion(depVersion)) {
maybeAliasedPackageVersions.set(depVersion, depName);
}
}
}
const packageNames = new Set();
// pnpm omits the packages block for workspace-only lockfiles (no external deps)
for (const [key, snapshot] of Object.entries(data.packages ?? {})) {
let packageNameObj;
const originalPackageName = extractNameFromKey(key, isV5);
if (!originalPackageName) {
continue;
}
// Extract version from the key to match against patch specifiers
const versionFromKey = getVersion(key, originalPackageName);
// Parse the base version (without peer dependency info, etc.)
const baseVersion = parseBaseVersion(versionFromKey, isV5);
// Find the appropriate patch hash using PNPM's priority order:
// 1. Exact version match, 2. Version range match, 3. Name-only match
const patchHash = findPatchHash(patchEntriesByPackage, originalPackageName, baseVersion);
const hash = createHashFromSnapshot(snapshot, patchHash);
// snapshot already has a name
if (snapshot.name) {
packageNameObj = {
key,
packageName: snapshot.name,
hash,
};
}
const rootDependencyName = matchedDependencyName(data.importers['.'], key, originalPackageName) ||
matchedDependencyName(data.importers['.'], `/${key}`, originalPackageName) ||
// only root importers have devDependencies
matchPropValue(data.importers['.'].devDependencies, key, originalPackageName, 'devDependencies') ||
matchPropValue(data.importers['.'].devDependencies, `/${key}`, originalPackageName, 'devDependencies');
if (rootDependencyName) {
packageNameObj = {
key,
packageName: rootDependencyName,
hash,
};
}
if (!snapshot.name && !rootDependencyName) {
packageNameObj = {
key,
packageName: originalPackageName,
hash,
};
}
if (snapshot.peerDependencies) {
for (const [depName, depVersion] of Object.entries(snapshot.peerDependencies)) {
if (isAliasVersion(depVersion)) {
maybeAliasedPackageVersions.set(depVersion, depName);
}
}
}
if (snapshot.optionalDependencies) {
for (const [depName, depVersion] of Object.entries(snapshot.optionalDependencies)) {
if (isAliasVersion(depVersion)) {
maybeAliasedPackageVersions.set(depVersion, depName);
}
}
}
if (snapshot.dependencies) {
for (const [depName, depVersion] of Object.entries(snapshot.dependencies)) {
if (isAliasVersion(depVersion)) {
maybeAliasedPackageVersions.set(depVersion, depName);
}
}
}
if (packageNameObj) {
packageNames.add(packageNameObj);
}
const aliasedDep = maybeAliasedPackageVersions.get(`/${key}`);
if (aliasedDep) {
packageNames.add({
key,
packageName: aliasedDep,
hash,
alias: true,
});
}
const localAlias = maybeAliasedPackageVersions.get(key);
if (localAlias) {
packageNames.add({
key,
packageName: localAlias,
hash,
alias: true,
});
}
}
for (const { key, packageName, hash, alias } of packageNames) {
const rawVersion = findVersion(key, packageName, isV5, alias);
if (!rawVersion) {
continue;
}
const version = parseBaseVersion(rawVersion, isV5);
if (!version) {
continue;
}
if (!nodes.has(packageName)) {
nodes.set(packageName, new Map());
}
if (!nodes.get(packageName).has(version)) {
const node = {
type: 'npm',
name: version && !version.startsWith('npm:')
? `npm:${packageName}@${version}`
: `npm:${packageName}`,
data: {
version,
packageName,
hash: hash ?? (0, file_hasher_1.hashArray)([packageName, version]),
},
};
nodes.get(packageName).set(version, node);
if (!keyMap.has(key)) {
keyMap.set(key, new Set([node]));
}
else {
keyMap.get(key).add(node);
}
}
else {
const node = nodes.get(packageName).get(version);
if (!keyMap.has(key)) {
keyMap.set(key, new Set([node]));
}
else {
keyMap.get(key).add(node);
}
}
}
const hoistedDeps = (0, pnpm_normalizer_1.loadPnpmHoistedDepsDefinition)();
// Pre-build packageName -> key index for O(1) lookup instead of O(n) find() per package
const hoistedKeysByPackage = new Map();
for (const key of Object.keys(hoistedDeps)) {
if (key.startsWith('/')) {
// Extract package name from key format: /{packageName}/{version}... or /@scope/name/{version}...
const withoutSlash = key.slice(1);
const slashIndex = withoutSlash.startsWith('@')
? withoutSlash.indexOf('/', withoutSlash.indexOf('/') + 1)
: withoutSlash.indexOf('/');
if (slashIndex > 0) {
const pkgName = withoutSlash.slice(0, slashIndex);
if (!hoistedKeysByPackage.has(pkgName)) {
hoistedKeysByPackage.set(pkgName, key);
}
}
}
}
const results = {};
for (const [packageName, versionMap] of nodes.entries()) {
let hoistedNode;
if (versionMap.size === 1) {
hoistedNode = versionMap.values().next().value;
}
else {
const hoistedVersion = getHoistedVersion(packageName, isV5, hoistedKeysByPackage);
hoistedNode = versionMap.get(hoistedVersion);
}
if (hoistedNode) {
hoistedNode.name = `npm:${packageName}`;
}
versionMap.forEach((node) => {
results[node.name] = node;
});
}
// `link:` dependencies pointing outside the workspace never appear in the
// packages section, so nothing above minted a node for them — and a task
// input naming one via `externalDependencies` fails hashing with "could not
// be found". Mint a node under the bare `npm:<name>`, hashed on the link
// path: the hash changes only when the path does, not when the linked
// content does, which is how linked dependencies behave everywhere else in
// Nx. Links that resolve inside the workspace are workspace projects, not
// externals, and are skipped.
//
// One node per package name, chosen deterministically by visiting importers
// root-first, then lexicographically. An `externalDependencies` input names
// a package, not a link target, so it could not distinguish two linked
// targets anyway.
const importerPaths = Object.keys(data.importers ?? {}).sort((a, b) => a === '.' ? -1 : b === '.' ? 1 : a.localeCompare(b));
for (const importerPath of importerPaths) {
const importer = data.importers[importerPath];
for (const depType of [
'dependencies',
'devDependencies',
'optionalDependencies',
]) {
const deps = importer[depType];
if (!deps) {
continue;
}
for (const [depName, depVersion] of Object.entries(deps)) {
if (typeof depVersion !== 'string' || !depVersion.startsWith('link:')) {
continue;
}
if (!linkTargetEscapesWorkspace(importerPath, depVersion)) {
continue; // inside the workspace -> a workspace project
}
const bareName = `npm:${depName}`;
if (results[bareName]) {
continue; // a hoisted registry version or an earlier link wins
}
results[bareName] = {
type: 'npm',
name: bareName,
data: {
version: depVersion,
packageName: depName,
hash: (0, file_hasher_1.hashArray)([depName, depVersion]),
},
};
}
}
}
return { nodes: results, keyMap };
}
function getHoistedVersion(packageName, isV5, hoistedKeysByPackage) {
let version = (0, package_json_1.getHoistedPackageVersion)(packageName);
if (!version) {
// Use pre-built index for O(1) lookup
const key = hoistedKeysByPackage.get(packageName);
if (key) {
version = parseBaseVersion(getVersion(key.slice(1), packageName), isV5);
}
else {
// pnpm might not hoist every package
// similarly those packages will not be available to be used via import
return;
}
}
return version;
}
function getDependencies(data, keyMap, isV5, ctx) {
const results = [];
// pnpm omits the packages block for workspace-only lockfiles (no external deps)
Object.keys(data.packages ?? {}).forEach((key) => {
const snapshot = data.packages[key];
const nodes = keyMap.get(key);
nodes.forEach((node) => {
[snapshot.dependencies, snapshot.optionalDependencies].forEach((section) => {
if (section) {
Object.keys(section).forEach((name) => {
const versionRange = section[name];
const version = parseBaseVersion(findVersion(versionRange, name, isV5), isV5);
const target = ctx.externalNodes[`npm:${name}@${version}`] ||
ctx.externalNodes[`npm:${name}`];
if (target) {
const dep = {
source: node.name,
target: target.name,
type: project_graph_1.DependencyType.static,
};
(0, project_graph_builder_1.validateDependency)(dep, ctx);
results.push(dep);
}
});
}
});
});
});
return results;
}
function parseBaseVersion(rawVersion, isV5) {
return isV5 ? rawVersion.split('_')[0] : rawVersion.split('(')[0];
}
function stringifyPnpmLockfile(graph, rootLockFileContent, packageJson, workspaceRoot) {
const data = (0, pnpm_normalizer_1.parseAndNormalizePnpmLockfile)(rootLockFileContent);
const { lockfileVersion, importers } = data;
// pnpm omits the packages block for workspace-only lockfiles (no external deps)
const packages = data.packages ?? {};
const packageIndex = indexPackagesByName(packages, +lockfileVersion);
const workspaceModules = (0, get_workspace_packages_from_graph_1.getWorkspacePackagesFromGraph)(graph);
const manifestPeersCache = new Map();
const getManifestPeers = (importerPath) => {
let peers = manifestPeersCache.get(importerPath);
if (peers) {
return peers;
}
peers = { workspaceSiblings: [], localPathPeers: [] };
const manifestPath = (0, path_1.join)(workspaceRoot, importerPath, 'package.json');
if ((0, node_fs_1.existsSync)(manifestPath)) {
try {
const manifest = JSON.parse((0, node_fs_1.readFileSync)(manifestPath, 'utf-8'));
for (const [name, spec] of Object.entries(manifest.peerDependencies ?? {})) {
if (workspaceModules.has(name)) {
peers.workspaceSiblings.push(name);
}
else if (typeof spec === 'string' && (0, project_graph_pruning_1.isLocalPathSpecifier)(spec)) {
peers.localPathPeers.push([name, spec]);
}
}
}
catch {
// Fall back to the lockfile importer only.
logger_1.logger.warn(`Could not read ${manifestPath} while pruning the pnpm lockfile; a peer dependency it declares may be missing from the pruned lockfile.`);
}
}
manifestPeersCache.set(importerPath, peers);
return peers;
};
const { snapshot: rootSnapshot, importers: requiredImporters } = mapRootSnapshot(packageJson, importers, packages, packageIndex, graph, workspaceModules, +lockfileVersion, workspaceRoot);
const snapshots = mapSnapshots(packages, packageIndex, graph.externalNodes, +lockfileVersion);
// Walk transitive workspace deps so every module copy-workspace-modules
// writes to disk gets a matching directory-package entry. Without this, pnpm
// errors with ERR_PNPM_OUTDATED_LOCKFILE on transitive workspace chains.
const allRequiredImporters = { ...requiredImporters };
const queue = Object.keys(requiredImporters);
const enqueueWorkspaceModule = (depName) => {
if (workspaceModules.has(depName) && !(depName in allRequiredImporters)) {
allRequiredImporters[depName] = workspaceModules.get(depName).data.root;
queue.push(depName);
}
};
while (queue.length > 0) {
const pkgName = queue.shift();
const importerPath = allRequiredImporters[pkgName];
const importer = importers[importerPath];
if (importer) {
for (const depType of WORKSPACE_DEP_TYPES) {
const deps = importer[depType];
if (!deps)
continue;
for (const depName of Object.keys(deps)) {
enqueueWorkspaceModule(depName);
}
}
}
// Peers pnpm did not auto-install are absent from the importer above; pull
// them from the manifest so their directory package is emitted too.
for (const depName of getManifestPeers(importerPath).workspaceSiblings) {
enqueueWorkspaceModule(depName);
}
}
// Emit each copied workspace module as a pnpm `file:` directory dependency,
// the shape `pnpm install` produces natively for a file: dependency. A module
// becomes a package keyed `<name>@file:workspace_modules/<name>` with a
// directory resolution and its resolved production closure; inter-module edges
// become `file:` refs. This is what a standalone production install expects:
// the modules resolve as file: directory packages, not workspace packages, so
// no `packages:` workspace file and no importer blocks for them.
const workspaceModulePackages = {};
// `<name>@file:<relocated-path>` keys for backfilled file: peers, mapped to
// the relocated target path; entries are synthesized after the loop.
const localPathPeerEntries = new Map();
// Output paths for the copied workspace modules, so the relocation pass can
// recognize them by identity. A workspace directory named `workspace_modules`
// is a real source that must relocate, and only the assembly knows which of
// the two a path is.
const synthesizedModulePaths = new Set();
// Snapshots whose manifest-declared peers are backfilled after the relocation
// pass, so the refs that pass writes are not relocated a second time.
const pendingPeerBackfills = [];
for (const [packageName, importerPath] of Object.entries(allRequiredImporters)) {
const baseImporter = importers[importerPath];
if (!baseImporter)
continue;
const snapshot = {
resolution: {
directory: `workspace_modules/${packageName}`,
type: 'directory',
},
};
for (const depType of WORKSPACE_DEP_TYPES) {
const deps = baseImporter[depType];
if (!deps)
continue;
const resolved = {};
for (const [depName, ref] of Object.entries(deps)) {
// Sibling workspace modules resolve to their own directory package; npm
// deps (resolved peers included) keep the ref from the source importer.
if (workspaceModules.has(depName)) {
resolved[depName] = `file:workspace_modules/${depName}`;
}
else if (ref.startsWith('link:')) {
// pnpm reads a snapshot link: ref relative to the lockfile dir, so
// rebase the importer-relative ref onto the deploy root, relocated to
// its shipped location (file: refs are lockfile-dir-relative and get
// contained by containShippedLocalFilePaths below; an unshippable
// target keeps its ref, matching the copied manifest).
const relocation = (0, pruned_output_1.relocatePrunedLocalPathSpec)(ref, importerPath, '');
resolved[depName] = relocation?.spec ?? ref;
}
else {
resolved[depName] = ref;
}
}
snapshot[depType] = resolved;
}
pendingPeerBackfills.push({ snapshot, importerPath });
synthesizedModulePaths.add(`workspace_modules/${packageName}`);
workspaceModulePackages[`${packageName}@file:workspace_modules/${packageName}`] = snapshot;
}
// Relocate the source snapshots' link: refs before the merge below, while the
// assembly's own already-relocated entries are still separate from them.
(0, pruned_output_1.containShippedLocalLinkRefs)(snapshots);
const output = {
...data,
lockfileVersion,
importers: {
'.': rootSnapshot,
},
packages: { ...snapshots, ...workspaceModulePackages },
};
// Relocate vendored file: refs (keys, resolutions, snapshot/importer refs) to
// their shipped location under LOCAL_PATH_MODULES_DIR; link: refs and the
// manifest are already relocated upstream. Everything assembled below is
// relocated at its synthesis site, which is why it is added afterwards: a
// second pass over an already-relocated path cannot tell it apart from a
// workspace path that genuinely starts with the shipped directory's name.
// TODO(v24): throw on this collision like the colliding-patches case; today
// the copied workspace module silently supersedes the file: dependency.
(0, pruned_output_1.warnOnWorkspaceModulePathCollision)(snapshots, synthesizedModulePaths);
(0, pruned_output_1.containShippedLocalFilePaths)(output, synthesizedModulePaths);
// Peers pnpm left out of the importer (autoInstallPeers off) still ship as
// real dependencies, matching the copied manifest that moves every
// peer-declared workspace module or local path into dependencies. A
// local-path peer gets the snapshot edge pnpm records when it auto-installs
// the peer, relocated to its shipped location; an unshippable target keeps
// its spec, matching the copied manifest (copy-workspace-modules already
// warned).
for (const { snapshot, importerPath } of pendingPeerBackfills) {
const { workspaceSiblings, localPathPeers } = getManifestPeers(importerPath);
if (workspaceSiblings.length === 0 && localPathPeers.length === 0) {
continue;
}
snapshot.dependencies ??= {};
for (const depName of workspaceSiblings) {
snapshot.dependencies[depName] ??= `file:workspace_modules/${depName}`;
}
for (const [depName, spec] of localPathPeers) {
if (snapshot.dependencies[depName]) {
continue;
}
const relocation = (0, pruned_output_1.relocatePrunedLocalPathSpec)(spec, importerPath, '');
const ref = relocation?.spec ?? spec;
snapshot.dependencies[depName] = ref;
if (ref.startsWith('file:') && !relocation?.reason) {
localPathPeerEntries.set(`${depName}@${ref}`, ref.slice('file:'.length));
}
}
}
// A backfilled file: peer has no package entry to carry (pnpm never resolved
// it), so synthesize the entry pnpm itself writes when it auto-installs the
// peer: a directory resolution for a directory target, a tarball resolution
// for a packed file (integrity is optional for a local tarball). link: refs
// need no entry. Entries the prune already carries win, compared against the
// relocated keys since both sides are relocated by this point.
for (const [key, shippedPath] of localPathPeerEntries) {
if (key in output.packages) {
continue;
}
let isFile = false;
try {
// Read the source from its original workspace location; the ref, and so
// the entry the output ships, names the relocated location.
isFile = (0, node_fs_1.statSync)((0, path_1.join)(workspaceRoot, (0, pruned_output_1.uncontainLocalPath)(shippedPath))).isFile();
}
catch {
// Missing target: emit the directory shape; the install surfaces the
// missing path either way.
}
output.packages[key] = {
resolution: isFile
? { tarball: `file:${shippedPath}` }
: { directory: shippedPath, type: 'directory' },
};
}
output.packages = (0, object_sort_1.sortObjectByKeys)(output.packages);
stripStandaloneLockfileConfig(output);
return (0, pnpm_normalizer_1.stringifyToPnpmYaml)(output);
}
/**
* Removes settings a standalone, pruned lockfile cannot satisfy on its own.
*
* A pruned build output ships `package.json`, the lockfile, and the copied
* `workspace_modules/` directories. It carries no resolution-time pnpm config:
* any `pnpm-workspace.yaml` it emits holds only install-time settings
* (build-script approvals, `supportedArchitectures`), never `overrides`,
* `packageExtensions`, or catalogs. pnpm 11 also no longer reads the `pnpm` field
* from `package.json`, so the lockfile's stored config (`overrides`, `settings`,
* `catalogs`, ...) has no backing source in the output. pnpm validates these
* against that (now absent) config and aborts `pnpm install --frozen-lockfile`
* with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH. Their effect is already baked into the
* resolved snapshots, so removing them keeps the install identical.
*
* The fields come from `PNPM_RESOLUTION_CONFIG` in `pruned-output`, which pairs
* each one with the `pnpm.*` manifest key `stripPrunedLockfilePnpmConfig` drops
* from the emitted `package.json`, so the two strips cannot drift.
*
* `patchedDependencies` is kept, but scoped to the patches whose package
* survives the prune: an entry for a dropped package has no snapshot to attach
* to and aborts the install with a config mismatch. The `.patch` files and the
* matching config are carried into the pruned output separately (see
* `getPrunedPnpmPatchArtifacts` in pruned-output).
*/
function stripStandaloneLockfileConfig(lockfile) {
// `catalogs` is absent from the Lockfile type but present in pnpm 10+ files.
const config = lockfile;
for (const field of pruned_output_1.PNPM_LOCKFILE_RESOLUTION_CONFIG_FIELDS) {
delete config[field];
}
filterPatchedDependenciesToPrunedPackages(lockfile);
}
/**
* Drops `patchedDependencies` entries whose package is no longer in the pruned
* lockfile. pnpm matches each entry against an installed package, so a dangling
* entry aborts `pnpm install --frozen-lockfile` with a config mismatch.
*
* A patch key is `name`, `name@version`, or `name@range` (pnpm records the key
* verbatim, so a range key stays a range). Package keys are always versioned
* (`name@version`, with an optional `(peer@ver)`/`(patch_hash=...)` suffix), so
* matching mirrors `findPatchHash`: a name-only key matches any surviving
* version, otherwise the resolved version must equal the key's version or
* satisfy its range.
*/
function filterPatchedDependenciesToPrunedPackages(lockfile) {
if (!lockfile.patchedDependencies) {
return;
}
const packageKeys = Object.keys(lockfile.packages ?? {});
for (const patchKey of Object.keys(lockfile.patchedDependencies)) {
if (!patchKeyMatchesPrunedPackage(patchKey, packageKeys)) {
delete lockfile.patchedDependencies[patchKey];
continue;
}
// pnpm 9-10 record the patch path in the lockfile (object form), and pnpm
// --frozen-lockfile aborts with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH when it
// disagrees with the emitted config. Rewrite it to the same `patches/<...>`
// path the pruned output ships via the shared `normalizePrunedPatchPath`, so
// the lockfile and the config stay identical. pnpm 11 records a bare hash, so
// there is no path.
const entry = lockfile.patchedDependencies[patchKey];
if (entry && typeof entry === 'object' && 'path' in entry) {
const patch = entry;
patch.path = (0, pruned_output_1.normalizePrunedPatchPath)(patch.path);
}
}
if (Object.keys(lockfile.patchedDependencies).length === 0) {
delete lockfile.patchedDependencies;
}
}
/**
* Whether a `patchedDependencies` key still targets a package that survived the
* prune. Decomposes both the key and each package key into name + version and
* applies the same exact/range/name-only rules as `findPatchHash`.
*/
function patchKeyMatchesPrunedPackage(patchKey, packageKeys) {
const patchName = extractNameFromKey(patchKey, false);
const versionSpec = patchName === patchKey ? null : getVersion(patchKey, patchName);
return packageKeys.some((key) => {
if (extractNameFromKey(key, false) !== patchName) {
return false;
}
// Strip any `(peer@ver)`/`(patch_hash=...)` suffix to get the resolved version.
const version = getVersion(key, patchName).split('(')[0];
// A `file:` directory package is a copied workspace module, never the target
// of a patchedDependencies entry (which patches a versioned npm package); a
// name-only patch key must not latch onto a same-named workspace module.
if (version.startsWith('file:')) {
return false;
}
if (versionSpec === null) {
return true;
}
if (version === versionSpec) {
return true;
}
try {
return ((0, semver_1.validRange)(versionSpec) !== null && (0, semver_1.satisfies)(version, versionSpec));
}
catch {
return false;
}
});
}
function mapSnapshots(packages, packageIndex, nodes, lockfileVersion) {
const result = {};
Object.values(nodes).forEach((node) => {
const matchedKeys = findOriginalKeys(packages, packageIndex, node, lockfileVersion, { returnFullKey: true });
// the package manager doesn't check for types of dependencies
// so we can safely set all to prod
matchedKeys.forEach(([key, snapshot]) => {
if (lockfileVersion >= 9) {
delete snapshot['dev'];
result[key] = snapshot;
}
else {
snapshot['dev'] = false; // all dependencies are prod
remapDependencies(snapshot);
if (snapshot.resolution?.['tarball']) {
// tarballs are not prefixed with /
result[key] = snapshot;
}
else {
result[`/${key}`] = snapshot;
}
}
});
});
return result;
}
function remapDependencies(snapshot) {
[
'dependencies',
'optionalDependencies',
'devDependencies',
'peerDependencies',
].forEach((depType) => {
if (snapshot[depType]) {
for (const [packageName, version] of Object.entries(snapshot[depType])) {
if (version.match(/^[a-zA-Z]+.*/)) {
// remap packageName@version to packageName/version
snapshot[depType][packageName] = `/${version.replace(/([a-zA-Z].+)@/, '$1/')}`;
}
}
}
});
}
// Bucket package keys by their package name so a node only scans its own name's
// versions instead of every key (was O(nodes * allPackages)). v5 is excluded
// below and keeps the full scan: its standard keys use a "/" separator while
// tarball keys use "@", so a single name index would misfile v5 tarballs.
function indexPackagesByName(packages, lockfileVersion) {
const isV5 = lockfileVersion < 6;
const index = new Map();
for (const key of Object.keys(packages)) {
const name = extractNameFromKey(key, isV5);
let bucket = index.get(name);
if (!bucket)
index.set(name, (bucket = []));
bucket.push([key, packages[key]]);
}
return index;
}
// npm alias version is "npm:<name>@<ver>"; extract <name> the same way
// versionIsAlias does so the index lookup matches the alias branch below.
function aliasTargetName(version) {
return version.slice('npm:'.length, version.indexOf('@', 'npm:'.length + 1));
}
const NO_CANDIDATES = [];
function findOriginalKeys(packages, packageIndex, node, lockfileVersion, { returnFullKey } = {}) {
const { data: { packageName, version }, } = node;
const candidates = lockfileVersion >= 6
? (packageIndex.get(version.startsWith('npm:') ? aliasTargetName(version) : packageName) ?? NO_CANDIDATES)
: Object.entries(packages);
const matchedKeys = [];
for (const [key, snapshot] of candidates) {
// tarball package (legacy lockfile formats key these differently; on v9+ the
// version-keyed branch below returns the correct full `name@<spec>` key, so
// restricting this to <9 avoids emitting a duplicate, name-stripped key for
// file:/https: tarball packages).
if (lockfileVersion < 9 &&
key.startsWith(`${packageName}@${version}`) &&
snapshot.resolution?.['tarball']) {
matchedKeys.push([getVersion(key, packageName), snapshot]);
}
// standard package
if (lockfileVersion < 6 && key.startsWith(`${packageName}/${version}`)) {
matchedKeys.push([
returnFullKey ? key : getVersion(key, packageName),
snapshot,
]);
}
if (lockfileVersion >= 6 &&
lockfileVersion < 9 &&
key.startsWith(`${packageName}@${version}`)) {
matchedKeys.push([
// we need to replace the @ with / for v5-7 syntax because the dpParse function expects old format
returnFullKey
? key.replace(`${packageName}@${version}`, `${packageName}/${version}`)
: getVersion(key, packageName),
snapshot,
]);
}
if (lockfileVersion >= 9 && key.startsWith(`${packageName}@${version}`)) {
matchedKeys.push([
returnFullKey ? key : getVersion(key, packageName),
snapshot,
]);
}
// alias package
if (versionIsAlias(key, version, lockfileVersion)) {
if (lockfileVersion >= 9) {
// no postprocessing needed for v9
matchedKeys.push([key, snapshot]);
}
else {
// for root specifiers we need to ensure alias is prefixed with /
const prefixedKey = returnFullKey ? key : `/${key}`;
const mappedKey = prefixedKey.replace(/(\/?..+)@/, '$1/');
matchedKeys.push([mappedKey, snapshot]);
}
}
}
return matchedKeys;
}
// check if version has a form of npm:packageName@version and
// key starts with /packageName/version
function versionIsAlias(key, versionExpr, lockfileVersion) {
const PREFIX = 'npm:';
if (!versionExpr.startsWith(PREFIX))
return false;
const indexOfVersionSeparator = versionExpr.indexOf('@', PREFIX.length + 1);
const packageName = versionExpr.slice(PREFIX.length, indexOfVersionSeparator);
const version = versionExpr.slice(indexOfVersionSeparator + 1);
return lockfileVersion < 6
? key.startsWith(`${packageName}/${version}`)
: key.startsWith(`${packageName}@${version}`);
}
function mapRootSnapshot(packageJson, rootImporters, packages, packageIndex, graph, workspaceModules, lockfileVersion, workspaceRoot) {
const catalogManager = (0, catalog_1.getCatalogManager)(workspaceRoot);
const snapshot = { specifiers: {} };
const importers = {};
[
'dependencies',
'optionalDependencies',
'devDependencies',
'peerDependencies',
].forEach((depType) => {
if (packageJson[depType]) {
Object.keys(packageJson[depType]).forEach((packageName) => {
let version = packageJson[depType][packageName];
if (catalogManager?.isCatalogReference(version)) {
const resolved = catalogManager.resolveCatalogReference(workspaceRoot, packageName, version);
if (!resolved) {
throw new Error(`Could not resolve catalog reference for package ${packageName}@${version}.`);
}
version = resolved;
}
if (workspaceModules.has(packageName)) {
// The app may declare the module under dependencies,
// optionalDependencies, devDependencies, or peerDependencies. Route
// the lockfile entry into the matching section; peerDependencies
// collapse to dependencies to match the pruned manifest, which moves a
// peer-declared workspace module into dependencies (pnpm rejects a
// file: spec under peerDependencies).
const targetSection = depType === 'optionalDependencies'
? 'optionalDependencies'
: depType === 'devDependencies'
? 'devDependencies'
: 'dependencies';
let importerKeyForPackage;
for (const [importerPath, importerSnapshot] of Object.entries(rootImporters)) {
const workspaceDep = (importerSnapshot.dependencies &&
importerSnapshot.dependencies[packageName]) ||
(importerSnapshot.optionalDependencies &&
importerSnapshot.optionalDependencies[packageName]) ||
(importerSnapshot.devDependencies &&
importerSnapshot.devDependencies[packageName]);
if (workspaceDep) {
importerKeyForPackage = (0, path_1.join)(importerPath, workspaceDep.replace('link:', ''));
break;
}
}
// pnpm records no importer entry for a workspace peer when
// autoInstallPeers is off, so fall back to the module's own root. The
// pruned manifest still moves the peer into dependencies, so the root
// importer must reference its directory package either way.
importerKeyForPackage ??=
workspaceModules.get(packageName)?.data.root;
if (importerKeyForPackage) {
importers[packageName] = importerKeyForPackage;
// Specifier matches the app manifest's file: ref; the version is
// the directory package key's ref (no leading `./`).
snapshot.specifiers[packageName] =
`file:./workspace_modules/${packageName}`;
snapshot[targetSection] = snapshot[targetSection] || {};
snapshot[targetSection][packageName] =
`file:workspace_modules/${packageName}`;
}
}
else {
let node = graph.externalNodes[`npm:${packageName}@${version}`] ||
(graph.externalNodes[`npm:${packageName}`] &&
graph.externalNodes[`npm:${packageName}`].data.version === version
? graph.externalNodes[`npm:${packageName}`]
: (0, project_graph_pruning_1.findNodeMatchingVersion)(graph, packageName, version));
// A file:/link: local-path dependency records a path where a version
// would go, so the lookups above never match it; findLocalPathNode
// matches it on that path instead.
if (!node && (0, project_graph_pruning_1.isLocalPathSpecifier)(version)) {
node = (0, project_graph_pruning_1.findLocalPathNode)(graph, packageName, version);
}
// peer dependencies are mapped to dependencies
const section = depType === 'peerDependencies' ? 'dependencies' : depType;
if (!node) {
if (version.startsWith('link:')) {
// A link: needs no packages: entry; emit the manifest value
// (relocated to its shipped location by the pre-lockfile rewrite)
// directly.
snapshot.specifiers[packageName] = version;
snapshot[section] = snapshot[section] || {};
snapshot[section][packageName] = version;
return;
}
throw new Error(`Could not find external node for package ${packageName}@${version}.`);
}
snapshot.specifiers[packageName] = version;
snapshot[section] = snapshot[section] || {};
// pnpm keys a package by its real name, so an aliased dependency's ref
// carries the full key rather than the bare version the name-sharing
// case uses. A local-path alias reaches here (an `npm:` one is matched
// as an alias and already keeps its key), and dropping the name would
// leave the ref pointing at no entry in the packages section.
const aliased = lockfileVersion >= 9 && node.data.packageName !== packageName;
snapshot[section][packageName] = findOriginalKeys(packages, packageIndex, node, lockfileVersion, { returnFullKey: aliased })[0][0];
}
});
}
});
Object.keys(snapshot).forEach((key) => {
snapshot[key] = (0, object_sort_1.sortObjectByKeys)(snapshot[key]);
});
return { snapshot, importers };
}
function findVersion(key, packageName, isV5, alias) {
if (isV5 && key.startsWith(`${packageName}/`)) {
return getVersion(key, packageName);
}
// this matches v6 syntax and tarball packages
if (key.startsWith(`${packageName}@`)) {
return getVersion(key, packageName);
}
if (alias) {
const aliasName = isV5
? key.slice(0, key.lastIndexOf('/'))
: key.slice(0, key.indexOf('@', 2)); // we use 2 to ensure we don't catch the first @
const version = getVersion(key, aliasName);
return `npm:${aliasName}@${version}`;
}
// for tarball package the entire key is the version spec
return key;
}
function getVersion(key, packageName) {
return key.slice(packageName.length + 1);
}
function extractNameFromKey(key, isV5) {
const versionSeparator = isV5 ? '/' : '@';
if (key.startsWith('@')) {
// Scoped package (e.g., "@babel/core@7.12.5" or "@babel/core/7.12.5")
// Find the end of scope, then look for the first version separator after that
const scopeEnd = key.indexOf('/');
const sepIndex = scopeEnd === -1 ? -1 : key.indexOf(versionSeparator, scopeEnd + 1);
return sepIndex === -1 ? key : key.slice(0, sepIndex);
}
else {
// Non-scoped package (e.g., "react@7.12.5" or "react/7.12.5")
const sepIndex = key.indexOf(versionSeparator);
return sepIndex === -1 ? key : key.slice(0, sepIndex);
}
}