Skip to content

RFC: utoo-pm 支持 linked 安装策略 / linked install strategy #3196

Description

@elrrrrrrr

中文版

RFC:utoo-pm 支持 linked 安装策略

  • 状态:Draft
  • 日期:2026-06-22
  • 负责人:utoo-pm
  • 范围:crates/pmcrates/ruborist
  • 相关:RFC RFC: PM 安装期安全控制 #3145 的双语文档格式
  • 英文版:本文下半部分

摘要

本文提议为 utoo-pm 增加一个实验性的 linked 安装策略,在保持当前默认 hoisted 策略不变的前提下,提供一种更接近 pnpm 的 node_modules/.store + symlink 目录布局。

linked 策略的核心目标是减少依赖提升带来的幽灵依赖问题:应用或包不再因为某个传递依赖被提升到根 node_modules 而意外 require/import 到自己没有声明的依赖。该策略仍保留 Node.js 原生 node_modules 解析模型,不引入 PnP 式运行时,因此它是“减少幽灵依赖”,不是“绝对隔离”。

背景

当前 utoo-pm 的安装路径基本由锁文件中的 package path 驱动。crates/pm/src/helper/lock.rs 会按 node_modules 深度组织 package,crates/pm/src/service/install.rs 再把 package clone/link 到 cwd.join(path)。这与当前 resolver 生成的 hoisted 目录模型绑定较深。

crates/ruborist 侧也已经把“物理位置”建模进 package graph:crates/ruborist/src/resolver/builder.rs 在创建 package node 时根据父节点计算 node_modules/{name} 路径,crates/ruborist/src/model/graph.rs 的 child index 也服务于提升后的物理树。

因此,支持 linked 不只是新增一个 CLI 参数,还需要把“依赖解析结果”和“物理安装布局”拆开:resolver 仍负责确定版本、peer、workspace、source package 等语义关系;installer/materializer 再根据 installStrategy 决定落盘路径和 symlink 关系。

目标

  1. 增加实验性 linked 安装策略,与现有 hoisted 策略并存。
  2. 现阶段默认策略仍为 hoisted,避免破坏现有项目。
  3. 使用 node_modules/.store 保存真实 package 内容,消费方 node_modules/{name} 指向 store 内 package。
  4. 减少因为 hoisting 产生的幽灵依赖,让未声明的传递依赖默认不可被项目根直接解析。
  5. 保持 Node.js 原生模块解析兼容,不要求额外 loader 或 runtime hook。
  6. 为后续性能、磁盘占用、幽灵依赖治理和 monorepo 安装策略提供实验基础。

非目标

  1. 本 RFC 不提议把默认安装策略从 hoisted 改为 linked
  2. 本 RFC 不要求实现全局 content-addressable store;第一阶段可以使用项目内 node_modules/.store
  3. 本 RFC 不引入 PnP 或自定义 Node.js resolver。
  4. 本 RFC 不强制变更现有 lockfile 语义;如需新增 install metadata,应作为后续兼容设计。
  5. 本 RFC 不解决所有幽灵依赖场景,尤其不承诺屏蔽 Node.js 向上查找带来的根依赖可见性。

配置草案

命令行:

ut install --install-strategy hoisted
ut install --install-strategy linked

配置文件候选:

[pm]
install-strategy = "linked"

环境变量候选:

UTOO_INSTALL_STRATEGY=linked ut install

配置优先级建议:

CLI 参数 > 环境变量 > 项目配置 > 默认值 hoisted

第一阶段建议只开放 CLI 参数,项目配置和环境变量可在实现稳定后补齐。

目录结构示例

假设项目只直接依赖 a,而 a 依赖 b

{
  "dependencies": {
    "a": "1.0.0"
  }
}

当前 hoisted 策略可能生成:

app/
  node_modules/
    a/
    b/
  package.json

此时 app 自身没有声明 b,但 app 代码仍可能通过 Node.js 向根 node_modules 查找并加载到 b,这就是典型幽灵依赖。

linked 策略建议生成:

app/
  node_modules/
    a -> .store/a@1.0.0-<key>/node_modules/a
    .store/
      a@1.0.0-<key>/
        node_modules/
          a/
          b -> ../../b@1.0.0-<key>/node_modules/b
      b@1.0.0-<key>/
        node_modules/
          b/

此时:

  1. app 可以解析 a,因为根 node_modules/a 是显式入口。
  2. a 可以解析 b,因为 store 中 a 的依赖槽位链接到了 b
  3. app 默认不能解析 b,因为根 node_modules/b 不存在。

如果项目根也显式声明 b

{
  "dependencies": {
    "a": "1.0.0",
    "b": "1.0.0"
  }
}

则 linked 策略应额外生成根入口:

app/
  node_modules/
    a -> .store/a@1.0.0-<key>/node_modules/a
    b -> .store/b@1.0.0-<key>/node_modules/b
    .store/
      ...

这符合“声明即可以访问”的预期。

workspace 示例

假设 monorepo 结构如下:

repo/
  package.json
  packages/
    web/
      package.json
    ui/
      package.json

packages/web 声明依赖 react 和 workspace package ui

{
  "dependencies": {
    "react": "18.2.0",
    "ui": "workspace:*"
  }
}

linked 策略可以生成:

repo/
  node_modules/
    .store/
      react@18.2.0-<key>/
        node_modules/
          react/
  packages/
    web/
      node_modules/
        react -> ../../../node_modules/.store/react@18.2.0-<key>/node_modules/react
        ui -> ../../ui
    ui/
      package.json

建议原则:

  1. workspace package 作为 source package 时,优先保持到真实 workspace 目录的 symlink。
  2. registry package 进入 node_modules/.store
  3. 只有被当前 importer 声明的依赖才出现在该 importer 的 node_modules 下。
  4. workspace filter 安装时,只 materialize 当前 filter 需要的入口和依赖闭包。

设计方案

1. 引入安装策略模型

建议新增内部枚举:

enum InstallStrategy {
    Hoisted,
    Linked,
}

hoisted 保持现有路径计算和安装行为。

linked 引入新的 materialization 阶段,将解析出的依赖图转换为:

  1. store package realpath;
  2. importer dependency slot;
  3. package 内部 dependency slot;
  4. bin link;
  5. lifecycle script execution target。

2. 拆分解析路径和安装路径

现有 resolver graph 中的 package path 可以继续作为 lockfile 兼容路径或逻辑路径,但 linked 需要另建 physical layout:

resolved package identity
  -> store key
  -> store real path
  -> consumer node_modules symlink

第一阶段可以避免大规模改动 resolver,只在 pm reify 阶段基于 lock/package graph 计算 linked layout。

3. store key 设计

第一阶段建议使用稳定且可调试的 key:

<escaped-name>@<version>-<short-hash>

hash 输入建议包括:

  1. package name;
  2. version;
  3. resolved URL 或 source identity;
  4. integrity;
  5. peer dependency resolution set;
  6. patch 或 package extension 标记;
  7. dependency set 中会影响 package 实际可见依赖的部分。

peer set 必须参与 store key,否则同一个 package 在不同 peer 环境下可能错误复用同一份物理实例。

4. symlink 规则

根 importer 依赖:

node_modules/<name> -> .store/<store-key>/node_modules/<name>

package 内依赖:

node_modules/.store/<pkg-key>/node_modules/<pkg>/node_modules/<dep>
  -> ../../../<dep-key>/node_modules/<dep>

实际相对路径应复用 crates/pm/src/util/linker.rs 中已有的相对 symlink 能力,避免硬编码路径分隔符。

本地 source 依赖:

  1. workspace:*:链接到 workspace 实际目录。
  2. link::保持 link 语义,链接到用户指定目录。
  3. file: directory:按现有语义决定复制、打包或链接,不强行纳入 registry store。
  4. file: tarball:可以 clone/extract 到 store,但 store key 需要包含 tarball identity/integrity。

5. bin 链接

linked 不能只链接 package 本体,还需要按消费方上下文生成 .bin

app/node_modules/.bin/<bin>
packages/web/node_modules/.bin/<bin>
node_modules/.store/<pkg-key>/node_modules/<pkg>/node_modules/.bin/<dep-bin>

建议原则:

  1. importer 的直接依赖 bin 出现在 importer 的 .bin
  2. package 的依赖 bin 出现在该 package 自己可见的 .bin
  3. 避免因为 store package 复用导致重复或错误覆盖 bin。

6. lifecycle scripts

linked 策略下真实 package 内容位于 store,scripts 也应以真实 package 为执行单位:

  1. 同一个 store package 只执行一次 install-time script。
  2. symlink 入口不重复执行 script。
  3. script 的 cwd 建议为 store 内真实 package path。
  4. 如脚本需要解析同 store 中的 sibling dependency,可评估是否注入 NODE_PATH 或等价环境。

这部分需要与 RFC #3145 的 install-time script policy 保持兼容:allowScriptsneverBuiltDependencies、workspace policy 等规则应作用在真实 package identity 上,而不是作用在 symlink 入口上。

7. prune 和幂等

linked 策略需要显式处理 .store 清理:

  1. 重复 install 应尽量 no-op。
  2. 移除依赖后,应删除无引用的 symlink。
  3. store 中无引用的 package key 应被 prune。
  4. 不应删除用户手工创建且不属于 utoo 管理的 symlink。
  5. 中断恢复时不能留下半成品入口。

建议在第一阶段维护轻量 install state,用于标记 utoo 创建的 linked layout:

node_modules/.utoo-install-state.json

是否引入该文件属于开放问题。如果可以完全由 lockfile 和当前 graph 计算得到,也可以先不新增状态文件。

和 pnpm 的关系

linked 与 pnpm 的相似点:

  1. 都把真实 package 内容放在 store-like 目录里。
  2. 都通过 symlink 构造每个 package 自己可见的依赖集合。
  3. 都能显著减少 hoisted 布局造成的幽灵依赖。
  4. 都保留 node_modules 形态,兼容大部分 Node.js 工具链。

主要差异:

  1. 本 RFC 第一阶段只要求项目内 node_modules/.store,不要求 pnpm 式全局 content-addressable store。
  2. pnpm 已有成熟的 lockfile、peer suffix、virtual store、workspace 协议和 side effects cache 设计;utoo linked 初期应视为实验实现。
  3. utoo linked 需要兼容现有 utoo lock/reify/resolver 模型,不能直接复制 pnpm 的 lockfile 语义。
  4. utoo 是否支持 public-hoist、shamefully-hoist 等逃生配置,应另行评估。

幽灵依赖影响

linked 策略可以显著减少幽灵依赖,但不能承诺完全避免。

能避免的典型情况:

app declares a
a declares b
app does not declare b

在 linked 下,根 node_modules/b 不存在,app 直接 require("b") 应失败。

不能完全避免的情况:

  1. 如果根项目显式声明了 b,则 package 通过 Node.js 向上查找仍可能看到根 b
  2. peer dependency 本来就是由上层提供,可见性需要遵循 peer 语义。
  3. 某些工具会主动扫描根 node_modules 或注入 resolver alias。
  4. 若未来提供 public-hoist 配置,被公开提升的依赖仍会重新变成可见依赖。

因此 RFC 中建议对外表述为:

linked reduces phantom dependencies caused by hoisting, but it is not a strict sandbox.

已知风险和测试清单

基于 npm install-strategy=linked 的调研,类似模式的高风险点包括:

  1. 重复安装 diff 不幂等,导致 symlink/store 被反复重建。
  2. 删除或升级依赖后,.store 残留 orphan package。
  3. workspace filter 与 linked layout 交互复杂,容易生成缺失入口或过多入口。
  4. peer dependency、optional peer、alias、scoped package 组合容易产生错误 store key。
  5. lifecycle scripts 在 store 中执行时,依赖解析环境可能与传统 hoisted 不一致。
  6. bin 链接可能遗漏、重复或指向错误 package 实例。
  7. file: tarball、file: directory、link:、workspace source package 的 clone/link 语义容易混淆。
  8. ut list 或依赖诊断命令可能把 symlink layout 误判为缺失依赖。
  9. 安装期安全策略、package extension、override、patch 等能力需要和 store key 共同设计。
  10. Windows junction/symlink 行为需要单独验证。

测试计划

建议新增 e2e fixtures:

  1. linked-basic:根依赖 aa 依赖 b,验证根不能加载未声明的 b
  2. linked-root-dep:根显式依赖 b,验证根可以加载 b
  3. linked-transitive-dep:验证 a 可以加载自己的 b
  4. linked-peer:同一 package 在不同 peer set 下生成不同 store key。
  5. linked-optional-peer:optional peer 缺失时不误报。
  6. linked-alias-scoped:覆盖 alias 和 scoped package。
  7. linked-workspace:workspace package 链接到真实 workspace 目录。
  8. linked-file-tarballfile: tarball 能安装到 store 并复用。
  9. linked-link-deplink: 保持用户指定路径。
  10. linked-bin:直接依赖和传递依赖 bin 链接位置正确。
  11. linked-scripts:store package script 只执行一次,并遵守 install-time script policy。
  12. linked-prune:移除依赖后清理入口和 orphan store package。
  13. linked-repeat-install:连续 install 后目录结构和 lockfile 无变化。
  14. linked-workspace-filter:filter install 只 materialize 必要闭包。

渐进落地

Phase 0:RFC 和 fixture

  1. 提交本 RFC。
  2. 增加最小 linked layout fixture 文档。
  3. 明确 CLI/API 命名和配置优先级。

Phase 1:实验 materializer

  1. 新增 InstallStrategy::Linked
  2. pm reify 阶段根据 lock/package graph 生成 linked layout。
  3. 支持 registry package store、root dependency link、package dependency link。
  4. 默认关闭,仅通过 CLI flag 开启。

Phase 2:兼容能力

  1. workspace、file、link、alias、scoped package。
  2. peer dependency store key。
  3. bin link。
  4. lifecycle scripts 与 script policy。
  5. prune、repeat install、interrupt recovery。

Phase 3:诊断和体验

  1. ut list 正确展示 linked layout。
  2. 安装日志显示 store/link 行为。
  3. 错误信息能区分 resolution failure、materialization failure 和 symlink failure。
  4. benchmark 对比 hoisted 和 linked 的时间、磁盘、文件数。

Phase 4:稳定性评估

  1. 仍保持 hoisted 为默认策略。
  2. 收集 monorepo 项目试用反馈。
  3. 根据测试和 benchmark 决定是否进入 beta。
  4. 是否未来允许项目级默认 linked,需另行 RFC 或决策。

开放问题

  1. install-strategy 的配置位置应放在 package.json.utoo.toml,还是现有 utoo 配置系统?
  2. 第一阶段是否需要 node_modules/.utoo-install-state.json
  3. store key 中 peer set 的编码格式如何设计,才能稳定、可读、可 diff?
  4. root dependency 对 transitive package 的向上可见性是否需要额外限制,还是只做文档说明?
  5. 是否需要类似 pnpm public-hoist-pattern 的 escape hatch?
  6. package-lock.json 是否需要记录 linked-specific metadata?
  7. linked 下 install-time scripts 的 cwd 和环境变量是否必须与 npm linked 对齐?
  8. Windows 下应统一使用 junction 还是 symlink?

English Version

RFC: Add a linked install strategy to utoo-pm

  • Status: Draft
  • Date: 2026-06-22
  • Owner: utoo-pm
  • Scope: crates/pm, crates/ruborist
  • Related: bilingual RFC format used by RFC RFC: PM 安装期安全控制 #3145
  • Chinese version: first half of this document

Summary

This RFC proposes an experimental linked install strategy for utoo-pm. The current default hoisted strategy remains unchanged. The new strategy introduces a pnpm-like node_modules/.store plus symlink layout.

The main goal is to reduce phantom dependencies caused by dependency hoisting. A project or package should not accidentally import a transitive dependency only because that dependency was hoisted to the root node_modules. The strategy still uses the native Node.js node_modules resolution model, so it reduces phantom dependencies but does not provide strict sandboxing.

Background

The current utoo-pm installation path is mostly driven by package paths from the lockfile. crates/pm/src/helper/lock.rs groups packages by node_modules depth, and crates/pm/src/service/install.rs clones or links each package into cwd.join(path). This is tightly coupled to the current hoisted physical layout.

crates/ruborist also models physical package locations in the graph. crates/ruborist/src/resolver/builder.rs computes a package node path from its physical parent, and crates/ruborist/src/model/graph.rs maintains child indexes for the hoisted physical tree.

Therefore, supporting linked is not just a new CLI option. We need to separate dependency resolution from physical materialization: the resolver should determine versions, peers, workspaces, source packages, and semantic edges; the installer/materializer should decide where files and symlinks are written based on installStrategy.

Goals

  1. Add an experimental linked install strategy alongside the existing hoisted strategy.
  2. Keep hoisted as the default for now.
  3. Store real package contents under node_modules/.store, with consumer node_modules/{name} entries symlinked to store packages.
  4. Reduce phantom dependencies introduced by hoisting.
  5. Stay compatible with Node.js native module resolution, without a loader or runtime hook.
  6. Provide an experimental base for future work on install layout, disk usage, phantom dependency control, and monorepo installation.

Non-goals

  1. This RFC does not propose changing the default strategy from hoisted to linked.
  2. This RFC does not require a global content-addressable store. The first version may use a project-local node_modules/.store.
  3. This RFC does not introduce PnP or a custom Node.js resolver.
  4. This RFC does not require changing existing lockfile semantics. Any linked-specific install metadata should be designed separately.
  5. This RFC does not claim to eliminate every phantom dependency case, especially dependencies visible through Node.js upward lookup.

Configuration proposal

CLI:

ut install --install-strategy hoisted
ut install --install-strategy linked

Candidate project config:

[pm]
install-strategy = "linked"

Candidate environment variable:

UTOO_INSTALL_STRATEGY=linked ut install

Suggested precedence:

CLI argument > environment variable > project config > default hoisted

For phase 1, exposing only the CLI flag is enough. Project config and environment variable support can be added after the implementation is more stable.

Layout example

Assume the project directly depends on a, and a depends on b:

{
  "dependencies": {
    "a": "1.0.0"
  }
}

The current hoisted strategy may produce:

app/
  node_modules/
    a/
    b/
  package.json

The app does not declare b, but app code may still load b through Node.js root node_modules lookup. This is a typical phantom dependency.

The linked strategy should produce:

app/
  node_modules/
    a -> .store/a@1.0.0-<key>/node_modules/a
    .store/
      a@1.0.0-<key>/
        node_modules/
          a/
          b -> ../../b@1.0.0-<key>/node_modules/b
      b@1.0.0-<key>/
        node_modules/
          b/

In this layout:

  1. app can resolve a, because root node_modules/a is an explicit entry.
  2. a can resolve b, because a has its own dependency slot linked to b.
  3. app cannot resolve b by default, because root node_modules/b does not exist.

If the root project also declares b:

{
  "dependencies": {
    "a": "1.0.0",
    "b": "1.0.0"
  }
}

Then linked should also create a root entry for b:

app/
  node_modules/
    a -> .store/a@1.0.0-<key>/node_modules/a
    b -> .store/b@1.0.0-<key>/node_modules/b
    .store/
      ...

This matches the rule that declared dependencies are visible to the declaring importer.

Workspace example

Assume the monorepo layout is:

repo/
  package.json
  packages/
    web/
      package.json
    ui/
      package.json

packages/web declares react and the workspace package ui:

{
  "dependencies": {
    "react": "18.2.0",
    "ui": "workspace:*"
  }
}

The linked strategy may produce:

repo/
  node_modules/
    .store/
      react@18.2.0-<key>/
        node_modules/
          react/
  packages/
    web/
      node_modules/
        react -> ../../../node_modules/.store/react@18.2.0-<key>/node_modules/react
        ui -> ../../ui
    ui/
      package.json

Suggested rules:

  1. Workspace packages should be symlinked to their real workspace directories.
  2. Registry packages should be materialized in node_modules/.store.
  3. Only dependencies declared by the current importer should appear in that importer's node_modules.
  4. Workspace filter installs should only materialize the selected importers and their required dependency closure.

Design

1. Add an install strategy model

Introduce an internal enum:

enum InstallStrategy {
    Hoisted,
    Linked,
}

hoisted should keep the current path computation and installation behavior.

linked should add a materialization phase that turns the resolved dependency graph into:

  1. store package real paths;
  2. importer dependency slots;
  3. package dependency slots;
  4. bin links;
  5. lifecycle script execution targets.

2. Separate resolution paths from installation paths

Package paths in the current resolver graph can continue to serve as lockfile-compatible or logical paths, but linked needs a separate physical layout:

resolved package identity
  -> store key
  -> store real path
  -> consumer node_modules symlink

In phase 1, we can avoid large resolver changes by computing the linked layout in the pm reify phase from the lock/package graph.

3. Store key design

The first version should use a stable and debuggable key:

<escaped-name>@<version>-<short-hash>

Suggested hash inputs:

  1. package name;
  2. version;
  3. resolved URL or source identity;
  4. integrity;
  5. peer dependency resolution set;
  6. patch or package extension markers;
  7. dependency set fields that affect the package's visible dependency environment.

The peer set must participate in the store key. Otherwise the same package may be incorrectly reused across different peer environments.

4. Symlink rules

Root importer dependency:

node_modules/<name> -> .store/<store-key>/node_modules/<name>

Package dependency:

node_modules/.store/<pkg-key>/node_modules/<pkg>/node_modules/<dep>
  -> ../../../<dep-key>/node_modules/<dep>

The actual relative path should reuse the existing relative symlink helper in crates/pm/src/util/linker.rs, instead of hard-coding path separators.

Local source dependencies:

  1. workspace:*: link to the real workspace directory.
  2. link:: keep link semantics and point to the user-specified path.
  3. file: directory: preserve the existing copy, pack, or link semantics instead of forcing it into the registry store.
  4. file: tarball: may be cloned/extracted into the store, with tarball identity/integrity included in the store key.

5. Bin links

linked must link not only package contents, but also .bin entries in the right consumer context:

app/node_modules/.bin/<bin>
packages/web/node_modules/.bin/<bin>
node_modules/.store/<pkg-key>/node_modules/<pkg>/node_modules/.bin/<dep-bin>

Suggested rules:

  1. Binaries from direct dependencies should appear in the importer's .bin.
  2. Binaries from package dependencies should appear in that package's visible .bin.
  3. Store reuse must not cause duplicated or incorrectly overwritten bin entries.

6. Lifecycle scripts

In linked mode, real package contents live in the store, so scripts should execute per real store package:

  1. The same store package should run install-time scripts only once.
  2. Symlink entries should not run scripts again.
  3. Script cwd should be the real package path inside the store.
  4. If scripts need to resolve sibling store dependencies, we should evaluate whether to inject NODE_PATH or an equivalent environment.

This must remain compatible with the install-time script policy from RFC #3145. allowScripts, neverBuiltDependencies, workspace policy, and related rules should apply to real package identities, not symlink entries.

7. Prune and idempotency

Linked mode needs explicit .store cleanup:

  1. Repeated installs should be mostly no-op.
  2. Removed dependencies should remove their stale symlink entries.
  3. Store package keys with no references should be pruned.
  4. User-created symlinks that are not managed by utoo should not be deleted.
  5. Interrupted installs should not leave half-created public entries.

A lightweight install state may be useful:

node_modules/.utoo-install-state.json

Whether to introduce this file is an open question. If the layout can be fully reconstructed from the lockfile and current graph, we can avoid adding state in phase 1.

Relationship to pnpm

Similarities:

  1. Both place real package contents in a store-like directory.
  2. Both use symlinks to construct the dependency set visible to each package.
  3. Both significantly reduce phantom dependencies caused by hoisting.
  4. Both keep a node_modules layout and remain compatible with most Node.js tools.

Key differences:

  1. This RFC only requires a project-local node_modules/.store for phase 1, not pnpm's global content-addressable store.
  2. pnpm already has mature designs for lockfile semantics, peer suffixes, virtual store, workspace protocols, and side effects cache. utoo linked should be treated as experimental at first.
  3. utoo linked must fit the existing utoo lock, reify, and resolver model. It should not copy pnpm lockfile semantics directly.
  4. Whether utoo should support escape hatches such as public hoist or shameful hoist should be evaluated separately.

Phantom dependency impact

Linked mode can significantly reduce phantom dependencies, but it cannot promise complete isolation.

Typical case it can prevent:

app declares a
a declares b
app does not declare b

In linked mode, root node_modules/b does not exist, so app should fail to require("b") directly.

Cases it cannot fully prevent:

  1. If the root project explicitly declares b, other packages may still see root b through Node.js upward lookup.
  2. Peer dependencies are intentionally provided by an ancestor, so their visibility must follow peer semantics.
  3. Some tools actively scan root node_modules or inject resolver aliases.
  4. If a future public-hoist option is added, publicly hoisted dependencies become visible again.

Recommended external wording:

linked reduces phantom dependencies caused by hoisting, but it is not a strict sandbox.

Known risks and test checklist

Based on the investigation of npm's install-strategy=linked, high-risk areas for this layout include:

  1. Repeated installs may not be idempotent and may rebuild symlinks or store entries unnecessarily.
  2. Removed or updated dependencies may leave orphan packages in .store.
  3. Workspace filters interact with linked layout and may create missing or excessive entries.
  4. Peer dependencies, optional peers, aliases, and scoped packages can produce incorrect store keys.
  5. Lifecycle scripts executed inside the store may observe a different resolution environment from traditional hoisted installs.
  6. Bin links may be missing, duplicated, or point to the wrong package instance.
  7. file: tarballs, file: directories, link:, and workspace source packages have different clone/link semantics and can be mixed up.
  8. ut list or dependency diagnostics may misread the symlink layout as missing dependencies.
  9. Install-time security policy, package extensions, overrides, and patches need to be designed together with store keys.
  10. Windows junction/symlink behavior needs separate verification.

Test plan

Add e2e fixtures:

  1. linked-basic: root depends on a, a depends on b, and root cannot load undeclared b.
  2. linked-root-dep: root explicitly depends on b, and root can load b.
  3. linked-transitive-dep: a can load its own b.
  4. linked-peer: the same package under different peer sets gets different store keys.
  5. linked-optional-peer: missing optional peers do not produce false failures.
  6. linked-alias-scoped: cover aliases and scoped packages.
  7. linked-workspace: workspace packages link to real workspace directories.
  8. linked-file-tarball: file: tarballs install into the store and can be reused.
  9. linked-link-dep: link: keeps the user-specified path.
  10. linked-bin: direct and transitive dependency bin links are created in the correct places.
  11. linked-scripts: store package scripts run once and obey install-time script policy.
  12. linked-prune: dependency removal cleans public entries and orphan store packages.
  13. linked-repeat-install: repeated installs do not change layout or lockfile.
  14. linked-workspace-filter: filter installs only materialize the required closure.

Rollout plan

Phase 0: RFC and fixtures

  1. Submit this RFC.
  2. Add minimal linked layout fixture documentation.
  3. Decide CLI/API naming and config precedence.

Phase 1: Experimental materializer

  1. Add InstallStrategy::Linked.
  2. Generate linked layout in the pm reify phase from the lock/package graph.
  3. Support registry package store, root dependency links, and package dependency links.
  4. Keep it disabled by default and enable it only through a CLI flag.

Phase 2: Compatibility

  1. Workspaces, file dependencies, link dependencies, aliases, and scoped packages.
  2. Peer dependency store keys.
  3. Bin links.
  4. Lifecycle scripts and script policy.
  5. Prune, repeated install, and interrupted install recovery.

Phase 3: Diagnostics and UX

  1. Make ut list display linked layout correctly.
  2. Show store/link operations in install logs.
  3. Distinguish resolution failures, materialization failures, and symlink failures in errors.
  4. Benchmark hoisted and linked on install time, disk usage, and file count.

Phase 4: Stability evaluation

  1. Keep hoisted as the default.
  2. Collect feedback from monorepo trials.
  3. Decide beta readiness based on tests and benchmarks.
  4. If project-level linked defaults are desired later, handle that in a separate RFC or decision.

Open questions

  1. Should install-strategy live in package.json, .utoo.toml, or the existing utoo config system?
  2. Does phase 1 need node_modules/.utoo-install-state.json?
  3. How should peer sets be encoded in store keys so they are stable, readable, and diffable?
  4. Should root dependencies remain visible to transitive packages through Node.js upward lookup, or should we attempt additional restrictions?
  5. Do we need an escape hatch similar to pnpm's public hoist patterns?
  6. Should package-lock.json record linked-specific metadata?
  7. Should linked install-time script cwd and environment match npm linked behavior exactly?
  8. Should Windows use junctions or symlinks by default?

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions