Skip to content

RFC: add ut outdated for direct dependencies #3228

Description

@elrrrrrrr

RFC:utoo-pm 支持 ut outdated

  • 状态:Draft
  • 日期:2026-07-13
  • 负责人:utoo-pm
  • 范围:crates/pmcrates/ruborist
  • 相关 Issue:#3210

摘要

本文提议为 utoo-pm 增加只读命令 ut outdated,用于展示项目依赖中存在新版本的 package。命令语义和输出优先对齐 npm outdated,包含 CurrentWantedLatest 三个版本列;它们不是命令参数:

  • Current:当前项目实际安装或锁定的版本;
  • Wanted:按照当前依赖声明重新解析时选择的版本;
  • Latest:registry latest dist-tag 指向的版本。

本期只检查 root 与目标 workspace 的直接依赖,不检查传递依赖。命令不修改 package.jsonpackage-lock.jsonnode_modules

背景

utoo 目前支持安装、更新、查看 registry package 信息和输出依赖树,但缺少一个低成本回答以下问题的命令:

  1. 当前实际安装了哪个版本?
  2. 在现有 semver range 内最多能升级到哪个版本?
  3. registry 当前发布的 latest 是哪个版本?
  4. 哪些依赖需要修改 package.json range 才能升级到 latest?

现有 ut update 会删除 lockfile 并重新安装依赖,不适合作为只读检查手段。用户在执行更新前需要先了解潜在变化。

utoo 已具备实现该能力的主要基础设施:

  • PackageLock / LockPackage 提供实际解析版本和 importer 的依赖声明;
  • FullManifest / VersionsInfo 提供 registry versions 和 dist-tags;
  • resolve_target_version 实现 npm 风格的 dist-tag、latest 优先和 max-satisfying;
  • manifest service、磁盘缓存、registry 配置和鉴权可以复用。

因此,本 RFC 不引入新的依赖解析算法,只组合现有 lockfile、registry manifest 和 semver 能力。

目标

  1. 增加 ut outdated 命令。
  2. 默认检查当前项目或当前 workspace 的直接依赖。
  3. 输出 npm 风格的 PackageCurrentWantedLatestLocationDepended by
  4. 覆盖 dependenciesdevDependenciespeerDependenciesoptionalDependencies
  5. 支持使用与 ut clean 相同的 package pattern 过滤。
  6. 支持 monorepo workspace 选择和全 workspace 检查。
  7. 复用现有 registry、认证、缓存、并发限制和 semver 语义。
  8. 保持命令完全只读。

非目标

  1. 不递归报告传递依赖,本期不提供 --all
  2. 不修改依赖 range。
  3. 不修改或重新生成 lockfile。
  4. 不安装 package,也不修改 node_modules
  5. 不取代安全漏洞审计;outdatedaudit 是不同能力。
  6. 首版不为 git、file、link、远程 tarball 等非 registry 来源查询版本;workspace:catalog: 按本文协议规则处理。
  7. 首版不提供自动交互式升级。
  8. 首版不实现 npm 的 --all 和 global mode。

用户体验

默认命令

ut outdated

输出示例:

Package     Current  Wanted  Latest  Location                 Depended by
react        18.2.0  18.3.1   19.1.0  node_modules/react       app
typescript    5.7.2   5.7.3    5.9.3  node_modules/typescript  app

Package pattern 过滤

位置参数支持一个或多个 package pattern,用于筛选目标 root/workspace 的直接依赖:

ut outdated react
ut outdated 'eslint*'
ut outdated '@types/*'
ut outdated react 'eslint*' '@types/*'

匹配规则直接复用 crate::util::cache::matches_pattern,与 ut clean 保持一致:

  • *:匹配全部 package;
  • react:精确匹配;
  • eslint*:前缀匹配;
  • *-plugin:后缀匹配;
  • eslint*plugin:首尾片段匹配;
  • @types/*:匹配 scope 下的 package。

多个 pattern 采用 OR 语义,命中任意一个即保留;没有位置参数时等价于 *。pattern 只匹配 dependency 的声明名称;npm alias legacy-react: npm:react@^17 应按 legacy-react 匹配,不按真实 registry package 名 react 匹配。

matches_pattern 当前不支持 !pattern 排除,因此本期也不增加排除语法。未来如果 cache-clean 的共享 matcher 增加排除能力,outdated 可自动沿用同一语义。

Workspace

建议提供:

ut outdated --workspace packages/app
ut outdated --workspace '@scope/app'
ut outdated --workspaces
  • 不传 workspace 参数:检查当前目录所属 workspace;若当前目录不属于 member,则检查 root package。
  • --workspace:可重复,复用现有 workspace 名称/路径/glob 选择语义。
  • --workspaces:检查 root 和所有 workspace member。

Depended by 始终显示声明该依赖的 root package 或 workspace。多 workspace 下同一个 package 可以出现多行,因为不同 importer 的声明范围和实际解析版本可能不同:

Package  Current  Wanted  Latest  Location            Depended by
react     18.2.0  18.3.1   19.1.0  node_modules/react  @utoo/app

版本语义

对于 importer 中声明的 registry 依赖 name: spec,定义:

Current = 实际依赖树中该 importer 依赖边指向的版本;不存在时为 MISSING
Wanted  = registry 中按照当前 spec 和安装策略解析出的版本
Latest  = registry dist-tags.latest

Current

npm 使用 Arborist.loadActual() 扫描实际安装树,因此 Current 表示磁盘现状,即使 node_modules 与 lockfile 不一致也能报告。Utoo 当前没有等价的完整 actual-tree reader,建议采用两阶段策略:

  1. 首版以 package-lock.json 中 importer edge 的解析结果作为 CurrentLocation
  2. 后续增加 actual-tree reader 后,优先使用磁盘实际版本,lockfile 只辅助建立依赖边和 location;
  3. 依赖边没有 target 时显示 MISSING,而不是把声明范围当作当前版本。

无论使用哪种来源,实现都必须通过 importer edge 定位 target,不能假设所有直接依赖都位于根 node_modules/{name}。workspace、hoisting 和嵌套安装都可能让同一个 package 名对应多个 location/version。

首版没有 lockfile 时返回可操作错误,建议用户先执行 ut install。这是 Utoo MVP 与 npm actual-tree 行为的明确差异。

Wanted

Wanted 使用当前依赖声明和 registry packument 进行版本选择:

  1. spec 是 dist-tag 时,选择对应 dist-tag;
  2. spec 是 semver range 时,复用 resolve_target_version
  3. npm alias 先解析真实 package 名和 alias 内部 spec;
  4. 版本选择应复用 install 的 engines、OS/CPU、prerelease、deprecated 和其他 manifest policy,避免 outdated 推荐一个 ut install 不会选择的版本;
  5. range 不存在匹配版本时按 npm 的 ETARGET 类错误策略跳过该依赖;
  6. 非 registry spec 按“协议语义”章节处理。

示例:

声明:^18.0.0
Current:18.2.0
可用版本:18.3.1、19.1.0
Wanted:18.3.1
Latest:19.1.0

Latest

Latest 严格使用 registry dist-tags.latest,不使用版本列表的数学最大值替代。这样与 npm registry 的发布语义一致,也能正确处理维护者移动 dist-tag 的情况。

如果 manifest 没有 latest dist-tag,该 package 记为无 latest,继续处理其他 package。

是否展示

建议首版采用语义明确的判断:

展示条件 = Current 不存在 || Current != Wanted || Wanted != Latest

这比单纯判断 Current < Latest 更稳健,可以展示以下异常或特殊状态:

  • 当前版本高于被回退的 latest
  • dist-tag 指向较旧版本;
  • 当前 lockfile 版本已不满足 package.json range;
  • prerelease 与稳定 dist-tag 产生非线性关系。

这里刻意使用版本 identity 判断,不假设三列单调递增。Wanted 可以高于 LatestCurrent 也可以高于 Latest。需要判断 major/minor/patch 时才使用 semver 比较,不能用字符串比较。

npm 10.9.3 参考实现

npm 的完整命令流程如下:

  1. 使用 Arborist.loadActual() 建立实际安装树;
  2. workspace filter 通过 Arborist 的 dependency set 限定 edge 来源;
  3. 无位置参数时收集 root 和 workspace 的直接 edgesOut
  4. npm 的 --all 会遍历 inventory 中所有 node 的 edgesOut,因此包含传递依赖;本期 Utoo 不实现该模式;
  5. 指定 package 名时,查询 inventory 中所有同名 node,再收集其 edgesIn
  6. 每条 edge 解析依赖类型、alias、当前 node、location 和 dependent;
  7. 跳过被 --omit 排除的 node、缺失的非生产依赖,以及非 registry spec;
  8. 通过 pacote.packument(..., { preferOnline: true }) 获取 packument;
  9. 通过 npm-pick-manifest 分别计算 WantedLatest
  10. 并发处理所有 edge,按 package 名和 dependent 排序;
  11. 有任何结果时将退出码设为 1;无结果时不输出内容;
  12. npm 命令自身支持 pretty、--json--parseable--long 四种输出;Utoo 本 RFC 不把这些横切能力建模为 Outdated 专属参数。

npm 对异常的处理不是“所有单包失败都降级”:目标版本不存在或 package 不存在会跳过,其他 registry/网络错误会使命令失败。Utoo 应沿用这一分类原则,但修正 npm 源码注释与实际错误码列表不一致的问题,显式列出可跳过错误。

默认、指定包与 npm --all

ut outdated
  root/workspace 直接 edgesOut

npm outdated foo
  npm 会查询实际树中名为 foo 的所有 node,再检查它们的 edgesIn

ut outdated 'foo*'
  Utoo 使用与 cache-clean 相同的 pattern matcher,
  只筛选 root/workspace 中声明名匹配 foo* 的直接 edge

npm outdated --all
  npm 扩展模式:实际树所有 node 的 edgesOut,包括传递依赖
  本期 Utoo 不实现

本 RFC 采用默认直接依赖查询,并允许通过 package pattern 缩小这些直接 edge 的范围。npm 的指定包查询可能命中传递实例,Utoo 本期不复制这一隐式扩展;传递依赖的 Wanted 受父 package range、override、peer context 和安装布局共同影响,输出噪声较大,本期不引入 --all

协议语义

outdated 必须先识别原始声明协议,再决定是否访问 registry。不能把所有 spec 都直接交给 semver resolver。

声明 Current Wanted Latest Registry 请求 行为
^1.2.0~1.2.0、exact、tag 实际 target 按声明解析 latest 正常检查
npm:real-name@^1 alias target 按真实包 real-name@^1 解析 真实包 latest Package 列保留 alias 关系
catalog: / catalog:name 实际 target 先展开 catalog entry,再按展开后的 spec 解析 package latest 保留 catalog 来源信息
workspace:* 本地 workspace version 本地 workspace version - 默认不作为 registry outdated 项
file: / link: / portal: 本地 target version 或 linked linked linked 默认不输出
git / GitHub 当前 checkout identity 不在首版计算 - 跳过
远程 tarball 当前 package version 不在首版计算 - 跳过

catalog:

Utoo 已支持默认 catalog 和命名 catalog:

{
  "dependencies": {
    "react": "catalog:",
    "lodash": "catalog:legacy"
  }
}

catalog: 本身不是 registry range。计算前必须通过项目根配置展开:

react: catalog:
  → default catalog 中 react 的 ^18.0.0
  → Wanted = 按 ^18.0.0 解析的版本

lodash: catalog:legacy
  → legacy catalog 中 lodash 的 ^4.17.0
  → Wanted = 按 ^4.17.0 解析的版本

规则:

  1. 复用 utoo_ruborist::spec::resolve_catalog_spec,不要重复实现 catalog 查找;
  2. catalog 或 package entry 不存在时报告配置错误,不能静默把 catalog: 当作 dist-tag;
  3. Current 仍来自每个 importer 的实际 edge target;
  4. Wanted 使用展开后的 range;
  5. Latest 使用 package registry 的 latest
  6. 多 workspace 引用同一 catalog dependency 时,首版仍按 dependent edge 分行展示,避免聚合后丢失 importer 信息;
  7. 内部结果保留 DeclaredResolved Spec,例如 catalog:legacy → ^4.17.0,供日志、诊断或未来通用详细输出能力使用。

catalog 配置发生变化但 lockfile 尚未同步时,Current 与新 Wanted 的差异正是 outdated 应展示的信息,不应直接以“lockfile outdated”为由拒绝整个命令。

workspace:

workspace: 表示必须解析到当前 monorepo 的本地 member,不表示去 registry 查询同名 package:

{
  "dependencies": {
    "@scope/core": "workspace:^"
  }
}

规则:

  1. 通过 workspace discovery 和已 settle 的 workspace edge 找到本地 target;
  2. Current 是本地 workspace package 的 version
  3. Wanted 也是本地 workspace version,因为 install 目标仍是该 member;
  4. Latest 显示 -,默认不请求 registry,也不因 registry 存在更高版本而报告 outdated;
  5. member 不存在,或显式 range 与 member version 不兼容时,报告 workspace 配置错误;
  6. workspace:*workspace:^workspace:~workspace:<range>workspace:./path 沿用现有 resolver/pack 语义;
  7. workspace 协议始终保持本地优先语义。

如果未来需要比较“本地 workspace version 与 registry latest”,应增加独立显式参数,例如 --workspace-registry,避免默认命令产生网络请求并暗示 workspace 应被远程版本替换。

npm alias

例如:

{
  "dependencies": {
    "legacy-react": "npm:react@^17"
  }
}

Registry 请求和版本计算针对 react@^17,但输出 Package 保留声明身份:

legacy-react -> react  17.0.1  17.0.2  19.1.0  ...

manifest cache 按真实 registry package 名 react 去重,不能按 alias 名请求不存在的 package。

依赖类型

内部结果保留 dependenciesdevDependenciespeerDependenciesoptionalDependencies 类型,用于 omit、排序和未来通用详细输出。普通表格不增加 Package Type 列,也不把类型拼接进 package 名。

如果同名依赖出现在多个 section,应遵循 npm package 语义去重。特别是 optionalDependencies 中的声明会覆盖同名 dependencies,不能输出两行冲突结果。

对缺失依赖,首版对齐 npm:缺失的生产依赖显示 MISSING;缺失的 dev、peer、optional dependency 不输出。--omit=dev|peer|optional 应在收集 edge 时过滤对应类型。

为什么不递归传递依赖

ut outdated 的目标是展示用户可以通过当前 importer 的 package.json 直接控制的升级项。传递依赖通常由其父 package 的 range 和 resolver 决定:

app
└── foo        ← app 的直接依赖,outdated 检查对象
    └── bar    ← foo 的传递依赖,不单独报告

递归报告会带来以下问题:

  1. 用户无法通过修改 app 的 package.json 直接升级 bar
  2. 同一个传递 package 可能以多个版本和 peer context 出现;
  3. 输出规模接近完整 lockfile,噪声远大于可操作信息;
  4. “可升级版本”必须同时考虑父依赖 range、override 和重新解析布局,语义不再是简单 outdated 查询。

如果未来确实需要完整依赖树检查,应单独提出 RFC,重新定义传递依赖的 edge 选择、override/peer 语义、重复实例展示和性能边界,而不是在本期命令中顺带加入 --all

Registry 和缓存

实现应先收集所有目标 importer 的直接 registry package 名称,再按名称去重并并发获取 manifest:

读取 importers
  → 收集直接依赖
  → package 名去重
  → 并发获取 manifests
  → 为每条 importer dependency 计算版本
  → 排序和输出

要求:

  1. 使用现有 registry 配置和 scoped registry 规则;
  2. 使用现有 token leak guard 和认证逻辑;
  3. 复用 manifest 内存/磁盘缓存和 ETag;
  4. 遵守 manifests_concurrency_limit
  5. 同一个 package 被多个 workspace 使用时只获取一次 manifest;
  6. ETARGET / E404 类“该依赖不可比较”错误可跳过;鉴权、网络、数据损坏等错误应使命令失败;
  7. 只请求版本选择所需 metadata,不为本期未展示的 Homepage 等字段获取完整 metadata。

outdated 对新鲜度的需求高于 install 的离线复现需求。默认应允许基于 ETag 向 registry 重新验证缓存,而不是永久使用磁盘中的旧 versions 数据。是否增加 --offline / --prefer-offline 不属于首版范围。

错误处理和退出码

对齐 npm 的退出码:

  • 0:命令成功且没有 outdated package;
  • 1:发现至少一个 outdated/MISSING package,或命令发生致命错误;
  • 无结果时不打印空表格和成功文案,保持 stdout 为空;
  • ETARGET / E404 等可跳过错误不单独改变退出码;
  • 鉴权、网络或 packument 解析失败等非可跳过错误终止命令。

退出码 1 同时表示“发现结果”和“执行失败”是 npm 的既有行为。未来可另行评估更细的退出码,但首版优先兼容 npm。

输出排序和颜色

稳定排序规则对齐 npm:先按 package 名,再按 Depended by 排序。

颜色语义对齐 npm:

  • Current != Wanted:package 名红色,表示当前声明范围内就有可更新版本;
  • Current == WantedWanted != Latest:package 名黄色,表示只有范围外的新版本;
  • Wanted 青色;
  • Latest 蓝色。

无 TTY、NO_COLOR 或现有 color 配置禁用颜色时,表格内容必须保持一致。

通用输出能力边界

--json--parseable--long 属于 CLI 查询命令的通用输出策略,不属于 outdated 版本计算的业务语义。本 RFC 只要求 service 返回稳定、无损的 OutdatedInfo 列表,并由现有或未来的统一 formatter 渲染普通表格。

本期约束:

  1. Commands::Outdated 不声明 jsonparseablelong 字段;
  2. service::outdated 不读取 CLI 输出选项;
  3. 普通模式只输出 npm 风格的核心表格;
  4. 如果仓库后续增加全局查询输出框架,可直接消费 OutdatedInfo,无需修改版本计算;
  5. 通用 JSON schema、parseable 分隔格式和 long 字段集合不在本 RFC 中冻结。

实现方案

CLI 层

crates/pm/src/cli.rs 增加:

Outdated {
    patterns: Vec<String>,
    #[arg(short, long)]
    workspace: Vec<String>,
    #[arg(long)]
    workspaces: bool,
}

constants.rs 增加命令名和说明,在 main.rs 中路由到 cmd::outdated

cmd 层

新增 crates/pm/src/cmd/outdated.rs,只负责:

  1. 定位 project root;
  2. 将 CLI 参数转换为 workspace/package pattern filter;
  3. 调用 service;
  4. 将结果交给 formatter。

业务逻辑不放在 cmd 层。

service 层

新增 crates/pm/src/service/outdated.rs,建议核心模型:

pub struct OutdatedInfo {
    pub package: String,
    pub registry_package: String,
    pub protocol: Protocol,
    pub dependency_type: DependencyType,
    pub dependent: String,
    pub declared: String,
    pub resolved_spec: Option<String>,
    pub current: Option<String>,
    pub wanted: Option<String>,
    pub latest: Option<String>,
    pub location: Option<String>,
}

核心步骤:

  1. 加载 root package、workspace 和 package-lock;
  2. 构建 importer edge 和 target/location 视图;
  3. 收集 root/workspace 直接 edge;位置参数通过共享 matches_pattern 从这些直接 edge 中筛选;
  4. 识别协议并展开 alias/catalog,workspace edge 留在本地;
  5. 按真实 registry package 名去重获取 manifests;
  6. 通过安装一致的 manifest picker 计算 Wanted
  7. 从 dist-tags 读取 Latest
  8. 按 npm 展示条件过滤并稳定排序;
  9. 将稳定结果交给普通表格 formatter 输出。

ruborist 层

首选复用 façade 已暴露的能力。如果 importer dependency 到 lock package target 的查找逻辑目前只存在于 resolver 内部,应在 utoo-ruborist 增加最小、只读的 façade API,而不是从 utoo_ruborist::resolver::model 内部路径导入。

不应为了 outdated 新建独立 crate。

测试计划

单元测试

  1. ^1.0.0:Current 落后于 range 内 Wanted。
  2. major latest:Wanted 保持当前 major,Latest 进入下一 major。
  3. exact version:Wanted 等于声明版本,Latest 更高。
  4. dist-tag spec:Wanted 使用该 dist-tag。
  5. prerelease range。
  6. registry latest 回退到低版本。
  7. Current 不满足当前 range。
  8. 缺少 latest dist-tag。
  9. optional 覆盖 dependencies 同名声明。
  10. 精确、前缀、后缀、中间和 scoped package pattern。
  11. 多 pattern 使用 OR 语义,空 pattern 列表等价于 *
  12. npm alias 按声明名匹配 pattern,使用真实 package 查询并保留 alias 展示名。
  13. catalog: 和命名 catalog 展开后计算 Wanted。
  14. 缺失 catalog entry 报配置错误。
  15. workspace: 不请求 registry,member 缺失/版本不匹配时报错。
  16. Current > LatestWanted > Latest 仍正常展示。
  17. ETARGET/E404 跳过,鉴权/网络错误终止。

集成测试

使用 mock registry,避免测试依赖真实公网:

  1. 单 package 项目的表格输出。
  2. 无 outdated package。
  3. 缺少 lockfile。
  4. package.json 与 lockfile 不同步时仍能基于 edge 展示 Current/Wanted 差异。
  5. scoped package 和 scoped registry。
  6. 多 workspace 使用同一个 package,只请求一次 manifest。
  7. --workspace 选择单个 workspace。
  8. --workspaces 输出正确的 Depended by。
  9. workspace: 本地处理,git、file、link 依赖被正确跳过。
  10. catalog 被多个 workspace 复用时 manifest 去重且 dependent 信息完整。
  11. 多 package pattern 正确筛选所有目标 importer 的直接依赖。
  12. 无颜色输出稳定。
  13. 有结果退出 1,无结果 stdout 为空且退出 0

渐进落地

Phase 1:单项目 MVP

  1. root package 的直接 registry dependencies;
  2. Current / Wanted / Latest
  3. 与 cache-clean 一致的 package pattern 过滤;
  4. mock registry 测试;
  5. 只读和稳定输出。

Phase 2:完整直接依赖类型与过滤

  1. dev、peer、optional 和 omit;
  2. npm alias、catalog:workspace:
  3. pattern 与 alias、scoped package 的组合行为;
  4. npm 兼容普通表格、颜色和退出码。

Phase 3:Workspace

  1. 当前 workspace 自动识别;
  2. --workspace--workspaces
  3. manifest 去重和 Depended by;
  4. workspace importer 到 lock target 的完整测试。

开放问题

  1. 何时引入 actual-tree reader,使 Current 从 lockfile 语义升级为 npm 的磁盘实际语义?
  2. 缺少 lockfile 时应失败,还是扫描 node_modules 并继续?MVP 建议失败。
  3. --workspaces 是否包含 root package?本 RFC 建议包含。
  4. 可跳过的 Registry 错误是否只限 ETARGET/E404,还是还包括 E403?应在实现前与 npm 当前行为和 Utoo registry error 类型一起确认。
  5. catalog: 多 workspace 行是否在普通表格聚合?建议首版不聚合,保持每行对应一个 dependent edge。
  6. workspace: 是否需要显式 --workspace-registry 比较远端 latest?默认不比较。

决策摘要

本 RFC 建议接受以下首要设计决策:

  1. ut outdated 是只读命令;
  2. 只检查 root/workspace 直接依赖,不提供 --all
  3. Current 的目标语义是实际安装版本,MVP 先从 lockfile edge 获取;
  4. Wanted 按当前声明和 install 一致的 manifest policy 解析;
  5. Latest 来自 registry latest dist-tag,不保证高于 Wanted/Current;
  6. catalog: 展开后查询 registry,workspace: 保持本地且默认不查询 registry;
  7. npm alias 按真实 package 查询,保留声明 alias;
  8. registry manifest 按真实 package 名去重并发获取;
  9. 普通表格、退出码和过滤行为优先对齐 npm;通用输出格式另行设计;
  10. 首版优先交付单项目 MVP,完整协议和 workspace 支持分阶段落地。

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions