Files
pnpm/pkg-manager/headless/src/linkHoistedModules.ts
Zoltan Kochan aeb06caae9 refactor: simplify patchedDependencies lockfile format (#10911)
* refactor: simplify patchedDependencies lockfile format to map selectors to hashes

Remove the `path` field from patchedDependencies in the lockfile, changing the
format from `Record<string, { path: string, hash: string }>` to
`Record<string, string>` (selector → hash). The path was never consumed from
the lockfile — patch file paths come from user config, not the lockfile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: migrate old patchedDependencies format when reading lockfile

When reading a lockfile with the old `{ path, hash }` format for
patchedDependencies, extract just the hash string. This ensures
backwards compatibility with existing lockfiles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: carry patchFilePath through patch groups for runtime patch application

The previous commit removed `path` from the lockfile format but also
accidentally dropped it from the runtime PatchInfo type. This broke
patch application since `applyPatchToDir` needs the file path.

- Add optional `patchFilePath` to `PatchInfo` for runtime use
- Build patch groups with resolved file paths in install
- Fix `build-modules` to use `patchFilePath` instead of `file.path`
- Fix `calcPatchHashes` call site in `checkDepsStatus` (extra arg)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: update remaining references to old PatchFile type

- Update getPatchInfo tests to use { hash, key } instead of { file, key }
- Fix createDeployFiles to handle patchedDependencies as hash strings
- Fix configurationalDependencies test assertion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: throw when patch exists but patchFilePath is missing

Also guard against undefined patchedDependencies entry when
ignorePackageManifest is true.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: don't join lockfileDir with already-absolute patch file paths

opts.patchedDependencies values are already absolute paths, so
path.join(opts.lockfileDir, absolutePath) created invalid doubled
paths like /project/home/runner/work/pnpm/...

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use path.resolve for patch file paths and address Copilot review

- Use path.resolve instead of path.join to correctly handle both
  relative and absolute patch file paths
- Use PnpmError instead of plain Error for missing patch file path
- Only copy patchedDependencies to deploy output when manifest
  provides the patch file paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: pass rootProjectManifest in deploy patchedDependencies test

The test was missing rootProjectManifest, so createDeployFiles could
not find the manifest's patchedDependencies to propagate to the
deploy output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:26:48 +01:00

159 lines
5.0 KiB
TypeScript

import path from 'path'
import { calcDepState, type DepsStateCache } from '@pnpm/calc-dep-state'
import {
progressLogger,
removalLogger,
statsLogger,
} from '@pnpm/core-loggers'
import type {
DepHierarchy,
DependenciesGraph,
} from '@pnpm/deps.graph-builder'
import { linkBins } from '@pnpm/link-bins'
import { logger } from '@pnpm/logger'
import type {
PackageFilesResponse,
StoreController,
} from '@pnpm/store-controller-types'
import pLimit from 'p-limit'
import { difference, isEmpty } from 'ramda'
import rimraf from '@zkochan/rimraf'
import type { AllowBuild } from '@pnpm/types'
const limitLinking = pLimit(16)
export async function linkHoistedModules (
storeController: StoreController,
graph: DependenciesGraph,
prevGraph: DependenciesGraph,
hierarchy: DepHierarchy,
opts: {
allowBuild?: AllowBuild
depsStateCache: DepsStateCache
disableRelinkLocalDirDeps?: boolean
force: boolean
ignoreScripts: boolean
lockfileDir: string
preferSymlinkedExecutables?: boolean
sideEffectsCacheRead: boolean
}
): Promise<void> {
// TODO: remove nested node modules first
const dirsToRemove = difference(
Object.keys(prevGraph),
Object.keys(graph)
)
statsLogger.debug({
prefix: opts.lockfileDir,
removed: dirsToRemove.length,
})
// We should avoid removing unnecessary directories while simultaneously adding new ones.
// Doing so can sometimes lead to a race condition when linking commands to `node_modules/.bin`.
await Promise.all(dirsToRemove.map((dir) => tryRemoveDir(dir)))
await Promise.all(
Object.entries(hierarchy)
.map(([parentDir, depsHierarchy]) => {
function warn (message: string) {
logger.info({
message,
prefix: parentDir,
})
}
return linkAllPkgsInOrder(storeController, graph, depsHierarchy, parentDir, {
...opts,
warn,
})
})
)
}
async function tryRemoveDir (dir: string): Promise<void> {
removalLogger.debug(dir)
try {
await rimraf(dir)
} catch (err: any) { // eslint-disable-line
/* Just ignoring for now. Not even logging.
logger.warn({
error: err,
message: `Failed to remove "${pathToRemove}"`,
prefix: lockfileDir,
})
*/
}
}
async function linkAllPkgsInOrder (
storeController: StoreController,
graph: DependenciesGraph,
hierarchy: DepHierarchy,
parentDir: string,
opts: {
allowBuild?: AllowBuild
depsStateCache: DepsStateCache
disableRelinkLocalDirDeps?: boolean
force: boolean
ignoreScripts: boolean
lockfileDir: string
preferSymlinkedExecutables?: boolean
sideEffectsCacheRead: boolean
warn: (message: string) => void
}
): Promise<void> {
const _calcDepState = calcDepState.bind(null, graph, opts.depsStateCache)
await Promise.all(
Object.entries(hierarchy).map(async ([dir, deps]) => {
const depNode = graph[dir]
if (depNode.fetching) {
let filesResponse!: PackageFilesResponse
try {
filesResponse = (await depNode.fetching()).files
} catch (err: any) { // eslint-disable-line
if (depNode.optional) return
throw err
}
depNode.requiresBuild = filesResponse.requiresBuild
let sideEffectsCacheKey: string | undefined
if (opts.sideEffectsCacheRead && filesResponse.sideEffectsMaps && !isEmpty(filesResponse.sideEffectsMaps)) {
if (opts?.allowBuild?.(depNode.name, depNode.version) !== false) {
sideEffectsCacheKey = _calcDepState(dir, {
includeDepGraphHash: !opts.ignoreScripts && depNode.requiresBuild, // true when is built
patchFileHash: depNode.patch?.hash,
})
}
}
// Limiting the concurrency here fixes an out of memory error.
// It is not clear why it helps as importing is also limited inside fs.indexed-pkg-importer.
// The out of memory error was reproduced on the teambit/bit repository with the "rootComponents" feature turned on
await limitLinking(async () => {
const { importMethod, isBuilt } = await storeController.importPackage(depNode.dir, {
filesResponse,
force: true,
disableRelinkLocalDirDeps: opts.disableRelinkLocalDirDeps,
keepModulesDir: true,
requiresBuild: depNode.patch != null || depNode.requiresBuild,
sideEffectsCacheKey,
})
if (importMethod) {
progressLogger.debug({
method: importMethod,
requester: opts.lockfileDir,
status: 'imported',
to: depNode.dir,
})
}
depNode.isBuilt = isBuilt
})
}
return linkAllPkgsInOrder(storeController, graph, deps, dir, opts)
})
)
const modulesDir = path.join(parentDir, 'node_modules')
const binsDir = path.join(modulesDir, '.bin')
await linkBins(modulesDir, binsDir, {
allowExoticManifests: true,
preferSymlinkedExecutables: opts.preferSymlinkedExecutables,
warn: opts.warn,
})
}