pnpmnodeLinker: hoisted下注入目录依赖的 peer 变体隔离:从折叠回退到独立副本的修复解析
【免费下载链接】pnpmFast, disk space efficient package manager项目地址: https://gitcode.com/gh_mirrors/pn/pnpm
本篇技术指南围绕 pnpm 仓库中的变更记录 .changeset/hoisted-file-dep-peer-variants-stay-apart.md 展开,深入剖析 pnpm 在
nodeLinker: hoisted布局下如何区分"注册表包的 peer 变体折叠"与"注入目录依赖(file:快照)的 peer 变体独立物化"两种行为。读完本文,你将理解 peer-resolution variant 在锁文件与 hoisted 布局中的表达方式、pkg_id归一化边界为何是修复的关键,以及 Bit 这类根组件工作流如何依赖这一行为,并看到对应的 Rust 源码实现与测试用例。
背景:nodeLinker: hoisted布局与 peer-resolution variant
pnpm 默认采用"符号链接虚拟存储"布局,但在nodeLinker: hoisted模式下,它会像 npm/yarn classic 一样把所有可提升的依赖拍平到node_modules顶层。这个布局由real-hoistcrate(对应 pnpm v11 的@pnpm/installing.linking.real-hoist)负责生成,其实现是@yarnpkg/nm提升算法的 Rust 移植:把 pnpm 锁文件翻译成一棵以.为根、每个 workspace importer 为子节点的HoisterTree,运行算法后再过滤掉externalDependencies。相关源码入口见 pnpm/crates/real-hoist/src/lib.rs。
锁文件中,同一个包版本可能因为解析到的 peer 依赖不同而产生多个快照,它们的快照键形如name@version(peer),括号后缀就是peer-resolution suffix(peer 解析变体)。在传统虚拟存储布局里,每个变体都是独立目录;而在 hoisted 布局中,hoister 会把这些变体当作同一版本去重:same_ident判定两个节点"版本相同"时会剥离(...)后缀,因此同一版本的 peer 变体在 hoisted 布局中共享同一份提升副本,不会互相遮蔽:
pnpm/crates/real-hoist/src/lib.rs的same_ident(lib.rs#L502-L521):比较ident_name与去掉了(之后版本的引用字符串;TreeCache::dep_key_by_pkg_id(tree.rs#L47-L61):把同一包版本的每个 peer 变体都映射到"第一次见到的快照键",作为它们的共享reference,hoister 因而只看到一个 locator,变体被去重而不是在每个依赖方下冲突嵌套。测试 peer_suffix_variants_collapse_to_one_hoisted_copy 明确记录:如果不折叠,peer 变体密集的锁文件(如@teambit/bit)会让 per-path 提升遍历发生组合爆炸。
配套变更 .changeset/hoisted-collapsed-peer-variant-edges.md 还修复了折叠后的边缘问题:hoisted布局下,针对某版本某个 peer 变体声明的依赖不再被从安装布局中丢弃——同一版本所有变体共享一份提升副本,指向任意变体的边都解析到它,依赖方项目因此在.package-map.json和node_modules/.bin中都能保留该包。
问题:注入目录依赖的 peer 变体被错误折叠
变更记录 .changeset/hoisted-file-dep-peer-variants-stay-apart.md 描述了一个回归及其修复:
Under
nodeLinker: hoisted, peer-resolution variants of an injected directory dependency (afile:snapshot) are materialized as separate copies again instead of collapsing onto the first-seen variant. Each copy keeps its own peer-resolved dependency set, so a project pinning one peer version no longer resolves another project's variant — Bit root components with conflicting peers across injected copies rely on this.
要点拆解:
- 注入目录依赖(injected directory dependency):即
file:快照对应的本地目录包(在injectWorkspacePackages: true或file:依赖场景下,pnpm 会把本地包直接注入依赖方目录,而不是符号链接)。 - 修复前:在 hoisted 布局中,这类目录依赖的 peer 变体被折叠到"第一个见到的变体"上,所有依赖方都解析到同一份副本。
- 修复后:每个变体重新以独立副本物化,各自携带自己的 peer-resolved 依赖集合。一个项目固定了某个 peer 版本,不会再意外解析到另一个项目的变体。
- 依赖方:Bit 根组件(root components)在不同注入副本之间故意固定互相冲突的 peer,这正是依赖此行为的工作流。
从源码注释可以确认该回归的背景:pkg_id的目录豁免注释(tree.rs#L285-L297)说明,注入目录依赖的每个变体都是本地包的一份独立磁盘副本,带有各自的 peer 解析依赖集;如果把变体折叠,输掉的那个变体的所有依赖方都会被改接到胜者的子依赖上——而 Bit 根组件正是故意在这些副本之间固定冲突的 peer。
修复核心:pkg_id的归一化边界
修复的关键落在pkg_id函数上(pnpm/crates/real-hoist/src/tree.rs#L298-L306):
#[must_use] pub fn pkg_id(dep_key: &PkgNameVerPeer) -> String { if let VersionPart::File(path) = dep_key.suffix.version() && !pnpm_lockfile::is_local_tarball_path(path) { return dep_key.to_string(); } dep_key.without_peer().to_string() }逻辑拆解:
- 目录依赖(非 tarball 的
file:路径):直接返回dep_key.to_string(),即保留 peer 后缀。每个 peer 变体拥有独立的包 id,hoister 会为它们各自建节点、各自物化副本; - 其他所有情况(注册表包、
file:*.tgz本地 tarball):返回dep_key.without_peer().to_string(),即剥离 peer 后缀。同一版本的所有变体共享一个包 id,hoister 折叠它们。
这个边界的依据与锁文件本身画线的依据一致——pnpm_lockfile::is_local_tarball_path正是锁文件对file:解析的分类函数。三类包的物理语义决定了行为:
| 包类型 | 物理形态 | pkg_id行为 |
|---|---|---|
| 注册表包 | 同一 tarball 解包 | 剥离 peer 后缀,变体折叠(防止变体爆炸) |
本地 tarball(file:*.tgz) | 每个变体解包同一归档 | 剥离 peer 后缀,同注册表包一样折叠 |
注入目录依赖(file:目录) | 每份是本地包的独立磁盘副本,带各自 peer 解析依赖集 | 保留 peer 后缀,变体独立物化 |
注释中明确了该设计的动机:注册表折叠是为了阻止大锁文件上的 peer 变体爆炸;而目录快照每个注入工作区包只有一份,不会以那种方式爆炸,因此没有折叠的必要,反而折叠会破坏依赖方解析。
独立物化后的布局语义
变体保持独立后,hoisted 布局中每个注入副本都保留自己的 peer-resolved 依赖集合。节点解析从 importer 出发向上层目录回溯(node resolution),因此 importer 看到的副本是嵌套在它自己子树里的那一份(或者它的变体被提升到根时的根副本)——只要通过这条路径到达的引用正是该 importer 声明的变体,布局就是正确的。
配套变更 .changeset/dedupe-injected-deps-unrelated-peer-suffix.md 也与此相关:它修复了注入工作区依赖(injectWorkspacePackages: true)在"无关的普通共享依赖为某项目解析出 peer 后缀变体、却未在注入场景中解析出该变体"时,错误地保持file:而没有去重回link:的问题(关联上游议题 pnpm/pnpm#10433 的复现场景)。两者一起构成了注入依赖在 peer 变体语境下"该独立则独立、该去重则去重"的完整语义。
测试验证:三种场景的行为边界
real-hoistcrate 的测试用代码构造锁文件后直接调用hoist(),逐一验证上述边界:
1. 注入目录依赖保持各自副本—— dependencies.rs#L316-L394 的file_dep_peer_variants_keep_their_own_copies测试,镜像了 teambit/bit 根组件布局:两个 importer(node_modules/.bit_roots/r1、r2)依赖同一个file:包comp,分别把 peerp固定在1.0.0和2.0.0:
for (importer_id, peer_ver) in [("node_modules/.bit_roots/r1", "1.0.0"), ("node_modules/.bit_roots/r2", "2.0.0")] { // comp 的快照键带 peer 后缀:file:comp(p@1.0.0) / file:comp(p@2.0.0) }断言每个 importer 解析到携带自身 peer 变体的副本:
assert_eq!( comp_reference_seen_by("node_modules/.bit_roots/r1"), "comp@file:comp(p@1.0.0)", "r1 must resolve the copy carrying its own peer variant", ); assert_eq!( comp_reference_seen_by("node_modules/.bit_roots/r2"), "comp@file:comp(p@2.0.0)", "r2 must resolve the copy carrying its own peer variant", );这正是本变更记录描述的"project pinning one peer version no longer resolves another project's variant"的代码级验证。
2. 本地 tarball 仍然折叠—— 同一文件 dependencies.rs#L399-L457 的file_tarball_peer_variants_collapse_like_registry_packages测试确认:目录豁免不能扩大化到本地 tarball,file:*.tgz的 peer 变体像注册表包一样折叠到根副本去重(断言 importer 下不再嵌套 tarball 变体节点)。
3. 注册表 peer 变体折叠到单份提升副本—— workspace_settings_hoist_throws_on_broken.rs#L783-L785 的peer_suffix_variants_collapse_to_one_hoisted_copy测试验证普通注册表包变体在根处去重而非冲突嵌套。
三组测试合起来验证了pkg_id边界的三个方向:目录独立、tarball 折叠、注册表折叠。
相关变更与影响范围
该变更记录标注了四个受影响的包,全部为patch级别:
@pnpm/installing.deps-restorer(Rustdeps-restorercrate,负责恢复/重链依赖)@pnpm/installing.linking.real-hoist(Rustreal-hoistcrate,hoisted 布局提升器)pacquet(pnpm 的 Rust 重实现)pnpm(主包)
同批次的 peer 变体相关变更还包括 .changeset/hoisted-collapsed-peer-variant-edges.md(折叠变体的依赖边不再丢失)、.changeset/record-injected-copies-from-the-current-lockfile.md(锁文件里存在、但无项目依赖的注入副本不再被错误记录进node_modules/.modules.yaml导致ERR_PNPM_INJECTED_DEPS_SYNC_READ_DIR)、以及 .changeset/dedupe-injected-deps-unrelated-peer-suffix.md(注入依赖去重回link:)。对使用nodeLinker: hoisted且同时启用injectWorkspacePackages或使用 Bit 根组件工作流的 monorepo,升级到包含此修复的版本后,应重新执行pnpm install(或pnpm install --frozen-lockfile)以按新语义重建 hoisted 布局与.package-map.json。
小结
本次修复的实质,是在 hoisted 布局的统一去重逻辑上为"注入目录依赖"划出一条精确的例外:注册表包与本地 tarball 的 peer 变体继续折叠,注入目录依赖的 peer 变体则保持独立副本、各自携带自己的 peer 解析依赖集。实现上只是一处pkg_id的分支判断,但背后是"折叠防止变体爆炸"与"独立保证 peer 解析正确"两种语义的权衡,而is_local_tarball_path这条与锁文件一致的边界线确保了行为在布局层面可预测、在测试层面可验证。
【免费下载链接】pnpmFast, disk space efficient package manager项目地址: https://gitcode.com/gh_mirrors/pn/pnpm
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考