关于 node-version-discipline
供处理 Node 项目的编码 Agent 使用的技能,要求在安装、构建、检查或测试前查明并切换到项目声明的 Node 版本;未声明时则按流程询问。
主要功能
- 01按优先级检查版本文件和 CI 配置
- 02运行敏感命令前对齐 Node 版本
- 03切换后确认当前版本
- 04未声明版本时先询问
- 05经用户同意后写入版本声明
适用场景
- 01安装依赖前检查 Node 约束
- 02避免构建、检查或测试结果失真
- 03处理未声明 Node 版本的项目
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ fudesign2008/ open-skills --skill node-version-discipline安装后在 Claude Code 中输入/node-version-discipline确认已加载。
附带文件evals/evals.json
详细介绍
Node 版本规范
在 Node 项目中运行任何构建、lint、类型检查、测试或安装命令之前,请将 Node 版本与项目声明的版本对齐(按完整优先级链探查:
.nvmrc→.node-version→.tool-versions→volta.node→engines.node→ CI 配置)。否则,验证结果不可信。
1. 为什么必须对齐
主机的默认 Node 版本(例如 v22)经常与项目通过 .nvmrc 固定的版本(例如 v14)不一致。在默认版本下运行 tsc / eslint / npm run build / npm test 会导致:
| 风险 | 影响 |
|---|---|
| 假通过 | 较高版本的 Node 更宽松——tsc/eslint 通过,但在固定版本上实际运行时会崩溃 |
| 假失败 | 较高版本会破坏旧版工具链(例如 Webpack 4 原生依赖),导致出现与代码无关的构建错误,误导排查 |
| 行为漂移 | 较旧版本缺少某些 API(structuredClone、AbortController 等)——运行时行为会出现差异 |
| 依赖解析偏差 | node_modules 会为当前启用的 Node 版本安装原生模块;版本不匹配会安装错误的变体 |
核心教训:即使较高版本碰巧通过,且结果恰好一致,
.nvmrc固定的版本仍是事实依据。“在较高版本上通过了”绝不能成为跳过版本对齐的理由。
2. 判定规则
何时对齐
如果项目通过任何方式声明了 Node 版本(参见下方的优先级链),请在运行以下命令之前进行对齐:
tsc/npx tsc/npm run type-checkeslint/npm run lintnpm run build/webpack/ 任何构建脚本npm test/jest/ 任何测试命令npm install/npm ci(在错误版本下安装依赖会拉取错误的原生模块——必须先完成探查和版本对齐,才能安装)- 任何
npm run <script>(脚本内部可能会调用对版本敏感的工具)
如果 npm install / npm ci 输出 Exit handler never called!(或类似的安装损坏信息),即使进程退出代码为 0,也应将安装视为失败:停止使用该 node_modules,重新对齐 Node 版本,清理后重新安装,然后再继续。不要在可疑的依赖树上运行 tsc、构建或测试。
版本声明优先级链
.nvmrc 是 nvm 的约定,并非唯一约定。各 Node 版本管理器读取的文件各不相同,因此请按顺序检查整条链——第一个权威命中项优先:
| # | 来源 | 类型 | 跨工具说明 |
|---|---|---|---|
| 1 | .nvmrc | 精确 | nvm 和 fnm 原生支持;asdf 需要 legacy_version_file;Volta 会忽略 |
| 2 | .node-version | 精确 | fnm / nodenv / nvs 原生支持;asdf 需要 legacy 标志;nvm 会忽略 |
| 3 | .tool-versions | 精确 | asdf 原生支持 |
| 4 | package.json → "volta".node | 精确 | Volta 原生支持——Volta 唯一会读取的来源 |
| 5 | package.json → engines.node | 范围 | 仅当其非退化时适用(见下文);除非启用 engine-strict,否则 npm 会发出警告;选择范围内已安装的版本 |
| 6 | CI / Docker 中固定的 Node 版本 | 精确 | 推断值——在报告中标明来源。候选文件:.gitlab-ci.yml、.github/workflows/*、Jenkinsfile、常见的 Dockerfile / Dockerfile.* 名称。识别 image: node:<ver>、node-version:、NODE_VERSION、FROM node:<ver>(镜像 / 版本标记周围可有可选的 YAML 引号) |
对于文件(第 1–3 行),请沿目录树向上查找;从项目根目录读取 package.json 和 CI / Docker 配置。第 1 行和第 2 行中这两个跨工具盲区并不明显——参见 §4。
退化的 engines.node: 将无法作为现代项目版本固定值的占位符 / 过时下限视为非权威值——至少包括 >=0.8.0、>0.8.0、>=0.10.0 及类似的过时下限。继续检查优先级链;询问用户时,不要将这些值与真正的 CI 版本固定值或 README 中的轶事并列为候选项。
适用于 Shell 的 CI / Docker 探查: 使用 [ -f path ](或 find / 可安全处理 nullglob 的循环)逐个检查候选路径。在 zsh nomatch 下,绝不要将必需的字面路径和可选 glob 放在同一个可展开的 argv 中(例如 ls .gitlab-ci.yml Dockerfile*——如果缺少 Dockerfile*,检查 .gitlab-ci.yml 之前就会中止)。优先使用固定的小型名称列表(Dockerfile、Dockerfile.web、……),并逐个路径检查;项目特定名称应单独使用 [ -f ] 检查,不要使用有风险的多重 glob ls。
没有版本声明时
如果检查链中没有找到任何版本声明,不要静默回退到默认 Node 版本,也不要擅自替用户选择版本——这正是此技能要避免的误通过/误失败陷阱,而猜测版本可能导致整个验证过程都在错误的 Node 版本上运行。将用户的回答保存为持久的项目资源,这样就不必重复这一轮确认:
- 停止并询问用户应使用哪个 Node 版本。提示用户:"此项目没有声明 Node 版本(未找到
.nvmrc/.node-version/.tool-versions/volta/engines.node/ CI 版本锁定)。运行 [tsc / build / test / ...] 前,我应对齐到哪个 Node 版本?" 等待用户回答;不要猜测版本后继续。 - 使用只读的
detect_manager代码片段(§2.2)检查工具链,以便推荐适合此项目/环境的版本声明文件。该代码片段只会输出信号,不会写入任何内容。 - 在持久化前征得用户同意。 提议写入推荐的版本声明文件,其中包含用户刚刚提供的确切版本(§2.1 用于选择文件类型;版本由用户指定,且必须采用完整的
X.Y.Z格式,例如22.5.0,不能只写主版本号)。只有用户同意后才进行持久化;如果用户拒绝,则跳过写入,本次会话仅进行版本对齐。无论用户是否同意写入,都要在运行实际命令前对齐到用户指定的版本(SOP §3)。 - 纯文件 I/O / git / grep 操作(不涉及 Node 运行时)无需进行版本对齐,也无需版本声明文件。
为什么要询问,而不是默认使用 Active LTS:项目“正确”的 Node 版本取决于 AI 并不了解的上下文,例如生产环境运行时、CI 矩阵、队友的环境和旧版兼容限制。错误猜测会悄无声息地导致所有下游结果失效。询问只需一次简单的往返确认;而误通过/误失败可能会让整个调查方向跑偏。
为什么要持久化答案(而不只是对齐一次):用户选择的版本是很有价值的工程信息。如果它只留在对话里,那么每个后续会话、CI 运行和协作者都会遇到同一个“没有版本声明”的障碍,还可能最终选择不同的版本。写入一个小型版本声明文件即可形成闭环——下次会话在首次检查时就会读取它,不再询问。
2.1 应写入哪种声明文件
根据 detect_manager(§2.2)报告的信号选择文件类型。优先使用第一个匹配的行;.nvmrc 是兜底默认选项,因为 nvm/fnm(以及启用了 legacy_version_file 的 asdf)都会读取它,因此能覆盖最广泛的工具。
| 信号(来自 §2.2) | 要写入的文件 | 内容格式 | 选择此文件的原因 |
|---|---|---|---|
package.json 已有 volta 块,或主机上已安装 volta | package.json → volta.node 字段 | "volta": { "node": "22.5.0" }(合并到现有的 volta 对象中) | Volta 只读取此来源;Volta 用户看不到 .nvmrc |
主机上已安装 asdf,或项目已有供其他语言使用的 .tool-versions | .tool-versions(追加一行) | nodejs 22.5.0 | asdf 的原生来源;将所有语言的版本固定在一个文件中 |
已安装 fnm 或 nodenv(且不符合以上条件) | .node-version | 22.5.0 | fnm/nodenv 的原生来源;nvm 会忽略此文件,因此如果只有 nvm,就不要选择它 |
| 以上条件均不满足/不确定 | .nvmrc | 22.5.0 | 覆盖范围最广:nvm + fnm 原生支持,asdf 通过 legacy_version_file 支持;安全的默认选项 |
版本格式 — 写入确切的补丁版本。 使用用户指定的完整 X.Y.Z 版本(如果用户说“使用已安装的版本”,则使用确切的已安装版本 — 通过 node -v 获取并去掉开头的 v)。不要只写主版本号(22),也不要写版本范围(>=20):声明文件的作用是固定一个可复现的版本,确保每个环境都一致。.nvmrc/.node-version 使用裸写的 22.5.0(不加 v 前缀,符合 nvm 约定);.tool-versions 使用 nodejs 22.5.0;engines.node/volta.node 使用带引号的字符串 "22.5.0"。(如果用户之后想为 engines.node 使用版本范围,那是需要另行明确决定的事项 — 此处的持久化步骤会固定确切版本。)
先持久化,再安装。 先写入声明文件,再处理安装。该文件表达了对 CI 和协作者的版本要求 — 不依赖本地机器是否已安装该版本。写入后,如果 ls ~/.nvm/versions/node/ 显示本地缺少该版本,则根据 SOP §3 步骤 2 提示运行 nvm install <version>(或对应版本管理器的命令),然后继续。这样一来,即使用户推迟安装,项目文件也已就位。
写入位置。 项目根目录(与 package.json 同级)。对于 .nvmrc/.node-version/.tool-versions,新建文件;对于 volta.node/engines.node,合并到现有的 package.json 中 — 绝不要覆盖文件,始终在原 JSON 中编辑。
2.2 detect_manager — 只读工具链探测
可直接粘贴运行,且只读。它会打印信号,以便 AI 根据 §2.1 选择正确的文件;不得写入任何文件。写入是单独的、需用户确认的步骤(§2 第 3 步)。
# Read-only: detect which Node version manager this project/env uses.
# Prints one signal per line. AI maps them to a declaration file via §2.1.
# Does NOT write anything — persistence is a separate, user-confirmed step.
[ -f package.json ] && node -e "require('./package.json').volta && console.log('volta-struct')" 2>/dev/null
command -v volta >/dev/null && echo "volta-installed"
command -v asdf >/dev/null && echo "asdf-installed"
[ -f .tool-versions ] && echo "tool-versions-exists"
command -v fnm >/dev/null && echo "fnm-installed"
command -v nodenv >/dev/null && echo "nodenv-installed"
# No output above → default to .nvmrc (broadest cross-tool coverage).
此探测脚本检测的是版本管理器,而非版本。版本始终由用户指定(§2 第 1 步) — 绝不能使用主机默认版本。
3. 标准操作规程(SOP)
步骤 1 — 检测版本约束
# Prefer .nvmrc (walk up the tree)
cat .nvmrc 2>/dev/null
# Fallback to package.json engines
node -e "console.log(require('./package.json').engines?.node || 'no engines constraint')" 2>/dev/null
为便于理解,上面的代码片段展示了两种最常见的来源。完整的跨工具优先级链(
.nvmrc→.node-version→.tool-versions→volta.node→engines.node→ CI 配置)已实现为 §7 中可直接粘贴运行的脚本 — 实际操作时请使用该脚本。
步骤 2 — 确认目标版本已安装
ls ~/.nvm/versions/node/ | grep <target-version>
尚未安装 → 提示用户运行 nvm install <version>,然后停止。
步骤 3 — 在一条命令中切换并运行(推荐)
source ~/.nvm/nvm.sh && nvm use <target-version> >/dev/null 2>&1 && node -v && <actual-command>
Shell 状态不会持久保留。
nvm use只影响当前 Shell 进程。每次 Bash 工具调用都可能启动独立的 Shell,因此nvm use不会在不同调用之间生效。每条对版本敏感的命令都必须在同一次调用中执行source ~/.nvm/nvm.sh && nvm use <version> && <command>— 绝不要拆成“先运行 nvm use,再运行命令”。
步骤 4 — 确认切换成功
切换后,务必先运行 node -v,再运行实际命令。nvm use 可能会静默失败(例如目标版本尚未安装);跳过确认,就可能误以为已切换,实际上并没有。
4. 常见错误模式(调用方必须拦截)
| 模式 | 后果 | 正确做法 |
|---|---|---|
直接运行 npx tsc --noEmit,忽略 .nvmrc | 结果不可信 | 先检测 .nvmrc,再运行 nvm use,然后运行命令 |
分两步操作:先运行 nvm use 14,再在下一次调用中运行 tsc | Shell 状态丢失,恢复为默认版本 | 在单次调用中运行:source && nvm use && cmd |
运行 nvm use 14 后不运行 node -v | 把静默失败误认为成功 | 切换后务必运行 node -v |
对未安装的版本运行 nvm use | 报错或静默失败 | 先运行 ls ~/.nvm/versions/node/;如果缺少该版本,提示运行 nvm install |
运行 nvm use node / nvm use default | 可能不会切换到 .nvmrc 指定的版本 | 使用准确的版本,或运行 nvm use $(cat .nvmrc) |
| “更高版本通过” ≡ “指定版本通过” | 错误地判定通过 | 指定版本才是唯一依据 |
项目有 .node-version,但你使用的是 nvm | nvm 忽略 .node-version → 静默回退到默认版本 | 探测完整链条(§2);如果只有 .node-version,运行 nvm use $(cat .node-version) |
项目通过 Volta 的 package.json "volta" 指定版本,但你使用的是 nvm/fnm | nvm/fnm 无法识别 Volta 指定的版本 | 读取 package.json 的 "volta".node,并对该准确版本运行 nvm use |
运行 ls .gitlab-ci.yml Dockerfile*(zsh 中没有 Dockerfile) | Glob 匹配失败 → 整个命令无法运行 → 错误地判定“没有 CI” | 对每个路径使用 [ -f ] / §7 中的 CI 探测方式;切勿将字面路径与未匹配的 Glob 混用 |
将 engines: ">=0.8.0" 当作指定版本 | 跳过真正的 CI 指定版本;给出荒谬的候选版本 | 对宽泛的 engines 声明,继续检查链条(§2) |
对齐版本前运行 npm install;退出码为 0 时忽略 Exit handler never called! | node_modules / 锁文件损坏;后续检查结果不可信 | 先对齐版本;将该消息视为安装失败(§2) |
5. 验证报告披露
报告验证结果时,必须披露 Node 版本,而不只是通过或失败:
## Code quality
- Node version (.nvmrc v14.21.3): ✅ switched via nvm use
- tsc --noEmit: ✅ 0 errors
- eslint: ✅ no errors/warnings
如果项目未声明版本,而用户提供了版本,必须明确披露这一事实,以便审计来源。还要说明该声明是否已持久化(以便下次会话自动对齐),还是仅在本次应用:
- Node version (user-specified v20.11.0, no prior project declaration): ✅ aligned; persisted to .nvmrc (22.5.0) — future sessions will auto-align
如果用户拒绝持久化,必须明确说明,以便清楚显示这一缺口:
- Node version (user-specified v20.11.0, no project declaration): ✅ aligned for this run; user declined to persist — declaration still missing, recommend adding .nvmrc
如果命令运行时未切换版本,标记 ⚠️ 并重新运行。
6. 两种集成约定
此技能是被依赖方,提供两种引用方式。
6.1 硬依赖约定(核心技能)
适用于将版本对齐作为硬性前提、容错为零的场景(tsc / eslint / build):
# in the caller's frontmatter
dependencies:
- node-version-discipline
调用方在启动时运行前置条件检查(与 solve-workflow 的模式相同):如果缺少此技能,立即中止并提示安装。不得静默降级——确保在任何环境中都能进行可信的验证。
6.2 软引用约定(扩展技能)
适用于将版本对齐视为最佳实践的场景(npm test / 浏览器调试 / 通用工程命令),在调用方正文的相关步骤中添加一行:
运行前,调用
node-version-discipline技能,将 Node 对齐到.nvmrc指定的版本。
不声明 dependencies,也不进行前置条件检查——依赖 AI 在该步骤主动调用。
7. 自包含检测脚本(可直接粘贴)
# Probe the full version-declaration chain (cross-tool), including CI/Docker, then switch via nvm.
# First authoritative hit wins. Shell-safe: per-path [ -f ], no mixed literal+glob ls.
# See §2 for priority, degenerate engines, and CI rules.
probe_file() { # $1 = filename; prints a version walking up the tree
for d in . .. ../.. ../../..; do
[ -f "$d/$1" ] && { cat "$d/$1" | grep -Eo 'v?[0-9]+(\.[0-9]+){0,2}' | head -1 | sed 's/^v//'; return; }
done
}
extract_node_pin() { # stdin → first node major/minor/patch from CI/Docker idioms
# Strip YAML/Actions quotes so `node-version: '18'` / `image: "node:14"` still match.
sed -E "s/[\"']//g" \
| grep -Eo '(image:[[:space:]]*node:|FROM[[:space:]]+node:|node-version:[[:space:]]*|NODE_VERSION[=:[:space:]]+)v?[0-9]+(\.[0-9]+){0,2}' \
| head -1 | grep -Eo 'v?[0-9]+(\.[0-9]+){0,2}' | head -1 | sed 's/^v//'
}
probe_ci() {
# Fixed literals only — never `ls … Dockerfile*` under zsh nomatch.
for f in .gitlab-ci.yml Jenkinsfile Dockerfile Dockerfile.web Dockerfile.node; do
[ -f "$f" ] || continue
ver="$(extract_node_pin < "$f")"
[ -n "$ver" ] && { echo "$ver"; return; }
done
if [ -d .github/workflows ]; then
# find avoids shell glob expansion failures
while IFS= read -r f; do
[ -f "$f" ] || continue
ver="$(extract_node_pin < "$f")"
[ -n "$ver" ] && { echo "$ver"; return; }
done < <(find .github/workflows -type f \( -name '*.yml' -o -name '*.yaml' \) 2>/dev/null)
fi
}
is_degenerate_engines() {
case "$1" in
'>=0.8.0'|'>0.8.0'|'>=0.10.0'|'>0.10.0'|'>=0.12.0') return 0 ;;
*) return 1 ;;
esac
}
NODE_VER="$(probe_file .nvmrc)"
[ -z "$NODE_VER" ] && NODE_VER="$(probe_file .node-version)"
[ -z "$NODE_VER" ] && NODE_VER="$(probe_file .tool-versions)"
if [ -z "$NODE_VER" ] && [ -f package.json ]; then
NODE_VER="$(node -e "const p=require('./package.json'); console.log(p.volta?.node || '')" 2>/dev/null)"
fi
if [ -z "$NODE_VER" ] && [ -f package.json ]; then
ENG="$(node -e "const p=require('./package.json'); console.log((p.engines&&p.engines.node)||'')" 2>/dev/null)"
if [ -n "$ENG" ] && ! is_degenerate_engines "$ENG"; then
NODE_VER="$(printf '%s' "$ENG" | grep -Eo '[0-9]+(\.[0-9]+){0,2}' | head -1)"
fi
fi
[ -z "$NODE_VER" ] && NODE_VER="$(probe_ci)"
if [ -n "$NODE_VER" ]; then
source ~/.nvm/nvm.sh 2>/dev/null && nvm use "$NODE_VER" >/dev/null 2>&1
echo "✅ Node: $(node -v) (aligned to $NODE_VER)"
else
echo "ℹ️ No declaration found — STOP and ask the user which Node version to use (do not guess). Current host default: $(node -v)"
echo " Then run detect_manager (§2.2) to pick a declaration file, confirm with the user, and persist it (§2.1) so future sessions auto-align."
fi
8. 对非 nvm 环境的回退方案
SOP 假定使用 nvm(Unix / macOS)。其他环境:
| 环境 | 回退操作 |
|---|---|
| Windows | 提示用户手动切换(nvm-windows / volta use);在报告中标记 ⚠️「未自动对齐,请手动确认版本」 |
| fnm / volta / asdf | 将 nvm use 替换为相应命令(fnm use / volta pin / asdf local nodejs <version>) |
| 容器 / CI | Node 版本由镜像固定;确认镜像版本与 .nvmrc 一致 |
切勿假装已完成对齐——未执行自动对齐时,务必明确说明。
9. 相关内容
- 来源:一个真实案例修复:某移动项目将
.nvmrc固定为 v14.21.3,而主机默认使用 v22,导致 tsc/eslint 在更高版本下误报通过。 - CI 假阴性:某项目仅在
.gitlab-ci.yml中通过image: node:14固定版本,却因 zsh 通配符展开中止而被误报为未声明——§2 的 shell 安全探测和 §7 的probe_ci可弥补这一遗漏。 - 无声明路径:项目完全未声明 Node 版本时,§2 会将用户的回答写入持久化声明文件(§2.1 会根据 §2.2 的工具链探测选择类型),从而形成闭环,使下一次会话在首次探测时就能读取该文件,而不必再次询问。
- 引用方:PDCA 主机和相关工具通过 frontmatter 中的
dependencies引用(主机是 SoT;此技能不会维护集成方名称列表)。test-suite-ensure可能会在测试前软引用对齐。