mcpField
N
node-version-discipline

node-version-discipline

github.com/fudesign2008Development671 installsUpdated 1d ago
Repository

About node-version-discipline

A skill for coding agents working in Node projects. It requires checking the project's declared Node version and aligning before installs, builds, linting, type checks, or tests, with a defined fallback when no version is declared.

Key features

  1. 01Probe version files and CI pins in priority order
  2. 02Align Node before version-sensitive commands
  3. 03Verify the active version after switching
  4. 04Ask before choosing an undeclared version
  5. 05Persist user-approved version declarations

Use cases

  1. 01Check Node constraints before installing dependencies
  2. 02Avoid misleading build, lint, or test results
  3. 03Handle projects with no declared Node version

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/fudesign2008/open-skills --skill node-version-discipline

After installing, type/node-version-disciplinein Claude Code to confirm it loaded.

Bundled filesevals/evals.json

Details

From the project README

Node Version Discipline

Before running any build / lint / type-check / test / install command in a Node project, align the Node version to the project's declared version (probe the full chain: .nvmrc → .node-version → .tool-versions → volta.node → engines.node → CI config). Otherwise verification results are untrustworthy.

1. Why alignment is mandatory

The host's default Node version (e.g. v22) frequently mismatches the project's .nvmrc-pinned version (e.g. v14). Running tsc / eslint / npm run build / npm test on the default version produces:

RiskEffect
False passHigher Node is more permissive — tsc/eslint pass, but the pinned version actually crashes at runtime
False failHigher Node breaks legacy toolchains (e.g. Webpack 4 native deps), surfacing build errors unrelated to the code and misdirecting the investigation
Behavioral driftOlder versions lack APIs (structuredClone, AbortController...) — runtime behavior diverges
Dependency resolution skewnode_modules installs native modules for whichever Node is active; mismatch installs the wrong variant

Core lesson: even if a higher version happens to pass and results coincidentally match, the .nvmrc-pinned version is the source of truth. "It passed on a higher version" never justifies skipping alignment.

2. Decision rules

When to align

If the project declares a Node version by any means (see the priority chain below), align before running:

  • tsc / npx tsc / npm run type-check
  • eslint / npm run lint
  • npm run build / webpack / any build script
  • npm test / jest / any test command
  • npm install / npm ci (installing deps on the wrong version pulls wrong native modules — never install before the probe+align step completes)
  • any npm run <script> (scripts may invoke version-sensitive tools internally)

If npm install / npm ci prints Exit handler never called! (or similar corrupt-install noise), treat the install as failed even when the process exit code is 0: stop using that node_modules, re-align Node, clean reinstall, then continue. Do not proceed to tsc/build/test on a suspect tree.

Version-declaration priority chain

.nvmrc is the nvm convention, not the only one. Node version managers disagree on which file they read, so probe the whole chain in order — first authoritative hit wins:

#SourceKindCross-tool notes
1.nvmrcexactnvm + fnm native; asdf needs legacy_version_file; Volta ignores
2.node-versionexactfnm / nodenv / nvs native; asdf needs legacy flag; nvm ignores
3.tool-versionsexactasdf native
4package.json → "volta".nodeexactVolta native — the only source Volta reads
5package.json → engines.noderangeOnly when non-degenerate (see below); npm warns unless engine-strict; pick an installed version inside the range
6CI / Docker Node pinexactInference — label the source in the report. Candidates: .gitlab-ci.yml, .github/workflows/*, Jenkinsfile, common Dockerfile / Dockerfile.* names. Recognize image: node:<ver>, node-version:, NODE_VERSION, FROM node:<ver> (optional YAML quotes around the image/version token)

Walk up the directory tree for files (rows 1-3); read package.json and CI/Docker config from the project root. The two cross-tool blind spots in rows 1 and 2 are non-obvious — see §4.

Degenerate engines.node: treat placeholder / ancient lower bounds that cannot be a modern project pin as non-authoritative — at minimum >=0.8.0, >0.8.0, >=0.10.0, and similarly stale floors. Continue the chain; do not present them as peer candidates next to a real CI pin or a README anecdote when asking the user.

Shell-safe CI/Docker probing: check each candidate path with [ -f path ] (or find / a nullglob-safe loop). Never put required literals and optional globs in one expandable argv under zsh nomatch (e.g. ls .gitlab-ci.yml Dockerfile* — a missing Dockerfile* aborts before .gitlab-ci.yml is checked). Prefer a small fixed name list (Dockerfile, Dockerfile.web, …) plus per-path tests; project-specific names get their own [ -f ], not a risky multi-glob ls.

When no declaration exists

If the chain finds nothing, do not silently fall back to the default Node or pick a version on the user's behalf — that is exactly the false-pass/false-fail trap this skill prevents, and guessing a version risks running the whole verification on the wrong Node. Turn the user's answer into a persistent project asset so this round-trip never has to repeat:

  1. Stop and ask the user which Node version to use. Prompt with: "This project declares no Node version (no .nvmrc / .node-version / .tool-versions / volta / engines.node / CI pin found). Which Node version should I align to before running [tsc / build / test / ...]?" Wait for the user's answer; do not proceed with a guessed version.
  2. Probe the toolchain with the read-only detect_manager snippet (§2.2) to recommend which declaration file fits this project/env. The snippet prints signals only — it does not write anything.
  3. Confirm with the user before persisting. Offer to write the recommended declaration file containing the exact version the user just gave (§2.1 picks the file type; the version is the user's, in full X.Y.Z form — e.g. 22.5.0, not a bare major). Persist only after the user agrees; if they decline, skip writing and continue with alignment-only for this session. Either way, align to the stated version (SOP §3) before running the real command.
  4. Pure file I/O / git / grep (no Node runtime) needs no alignment and no declaration file.

Why ask rather than default to Active LTS: the "correct" Node for a project depends on context the AI doesn't have — production runtime, CI matrix, teammate environments, legacy constraints. A wrong guess silently invalidates every downstream result. Asking is one cheap round-trip; a false pass/fail can misdirect an entire investigation.

Why persist the answer (not just align once): the user's version choice is high-value engineering information. If it lives only in the conversation, every future session, CI run, and collaborator hits the same "no declaration" wall and may converge on a different version. Writing one small declaration file closes the loop — the next session reads it on the first probe and never asks again.

2.1 Which declaration file to write

Pick the file type from the signals detect_manager (§2.2) reported. First matching row wins; .nvmrc is the catch-all default because nvm/fnm (and asdf with legacy_version_file) all read it, giving the broadest cross-tool coverage.

Signal (from §2.2)File to writeContent shapeWhy this file
package.json already has a volta block, or volta is installed on the hostpackage.json → volta.node field"volta": { "node": "22.5.0" } (merge into existing volta object)Volta reads only this source; a .nvmrc would be invisible to Volta users
asdf installed on host, or project already has a .tool-versions for other languages.tool-versions (append a line)nodejs 22.5.0asdf's native source; keeps all language pins in one file
fnm or nodenv installed (and neither of the above).node-version22.5.0fnm/nodenv native source; nvm ignores this file, so don't pick it when nvm is the only manager
None of the above / unsure.nvmrc22.5.0Broadest coverage: nvm + fnm native, asdf with legacy_version_file; the safe default

Version format — write the exact patch. Use the full X.Y.Z the user specified (or the exact installed version if the user said "use what's installed" — capture it via node -v and strip the leading v). Do not write a bare major (22) or a range (>=20): a declaration file's job is to pin one reproducible version so every environment converges identically. .nvmrc/.node-version take the bare 22.5.0 (no v prefix, nvm convention); .tool-versions takes nodejs 22.5.0; engines.node/volta.node take the quoted string "22.5.0". (If the user later wants a range for engines.node, that's a separate conscious choice — the persist step here pins exact.)

Persist-then-install order. Write the declaration file first, then handle installation. The file is a statement of intent for CI and collaborators — it does not depend on the local machine having that version. After writing, if ls ~/.nvm/versions/node/ shows the version is missing locally, prompt nvm install <version> (or the manager equivalent) per SOP §3 Step 2, then proceed. This way the project asset exists even if the user defers installation.

Where to write. Project root (same level as package.json). For .nvmrc/.node-version/.tool-versions create a new file; for volta.node/engines.node merge into the existing package.json — never overwrite the file, always edit the JSON in place.

2.2 detect_manager — read-only toolchain probe

Paste-ready, read-only. It prints signals so the AI can pick the right file per §2.1; it must not write any file. Writing is a separate, user-confirmed step (§2 step 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).

The probe detects the manager, not the version. The version always comes from the user (§2 step 1) — never from the host default.

3. Standard Operating Procedure (SOP)

Step 1 — Detect the constraint
# 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

The snippet above shows the two most common sources for clarity. The full cross-tool priority chain (.nvmrc → .node-version → .tool-versions → volta.node → engines.node → CI config) is implemented as a paste-ready script in §7 — use that in practice.

Step 2 — Confirm the target version is installed
ls ~/.nvm/versions/node/ | grep <target-version>

Not installed → prompt the user to nvm install <version> and stop.

Step 3 — Switch and run in a single command (recommended)
source ~/.nvm/nvm.sh && nvm use <target-version> >/dev/null 2>&1 && node -v && <actual-command>

Shell state is not persistent. nvm use only affects the current shell process. Each Bash tool call may spawn an independent shell, so nvm use does not carry across calls. Every version-sensitive command must do source ~/.nvm/nvm.sh && nvm use <version> && <command> inside the same call — never split into "nvm use first, then run".

Step 4 — Confirm the switch succeeded

After switching, always node -v before the real command. nvm use can fail silently (e.g. target not installed); skipping confirmation risks assuming a switch that never happened.

4. Common error patterns (callers must intercept)

PatternConsequenceCorrect approach
npx tsc --noEmit directly, ignoring .nvmrcUntrustworthy resultDetect .nvmrc first, nvm use, then run
Two steps: nvm use 14 then tsc in the next callShell state lost, reverts to defaultSingle call: source && nvm use && cmd
nvm use 14 without node -vSilent failure mistaken for successAlways node -v after switching
nvm use on an uninstalled versionErrors or silent failurels ~/.nvm/versions/node/ first; prompt nvm install if missing
nvm use node / nvm use defaultMay not hit the .nvmrc versionUse the exact version, or nvm use $(cat .nvmrc)
"Higher version passed" ≡ "pinned version passes"False passPinned version is the source of truth
Project has .node-version but you're on nvmnvm ignores .node-version → silent fall-back to defaultProbe the full chain (§2); if only .node-version exists, nvm use $(cat .node-version)
Project pins via Volta (package.json "volta") but you're on nvm/fnmVolta's pin is invisible to nvm/fnmRead package.json "volta".node and nvm use that exact version
ls .gitlab-ci.yml Dockerfile* (zsh, no Dockerfile)Glob fails → whole command never runs → false "no CI"Per-path [ -f ] / §7 CI probe; never mix literals with unmatched globs
Trust engines: ">=0.8.0" as the pinSkips real CI pin; offers nonsense candidatesDegenerate engines → continue chain (§2)
npm install before align; ignore Exit handler never called! when exit 0Corrupt node_modules / lockfile; later checks untrustworthyAlign first; treat that message as install failure (§2)

5. Verification report disclosure

When reporting verification results, disclose the Node version — not just pass/fail:

## Code quality
- Node version (.nvmrc v14.21.3): ✅ switched via nvm use
- tsc --noEmit: ✅ 0 errors
- eslint: ✅ no errors/warnings

When the project declared no version and the user supplied one, disclose that fact explicitly so the source is auditable. Distinguish whether the declaration was persisted (so the next session auto-aligns) or only applied this once:

- Node version (user-specified v20.11.0, no prior project declaration): ✅ aligned; persisted to .nvmrc (22.5.0) — future sessions will auto-align

If the user declined to persist, make that explicit so the gap is visible:

- Node version (user-specified v20.11.0, no project declaration): ✅ aligned for this run; user declined to persist — declaration still missing, recommend adding .nvmrc

If a command ran without switching, mark ⚠️ and re-run.

6. Two integration contracts

This skill is the dependee, offering two ways to be referenced.

6.1 Hard-dependency contract (core skills)

For zero-tolerance scenarios where alignment is a hard precondition (tsc / eslint / build):

# in the caller's frontmatter
dependencies:
  - node-version-discipline

The caller runs a precondition check at startup (same pattern as solve-workflow): if this skill is absent, abort immediately and prompt to install. No silent degradation — guarantees trustworthy verification in any environment.

6.2 Soft-reference contract (extension skills)

For scenarios where alignment is best practice (npm test / browser debug / general engineering commands), add one line at the relevant step in the caller body:

Before running, invoke the node-version-discipline skill to align Node to .nvmrc.

No dependencies declared, no precondition check — relies on the AI invoking it consciously at that step.

7. Self-contained detection script (paste-ready)

# 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. Fallback for non-nvm environments

The SOP assumes nvm (Unix / macOS). Other environments:

EnvironmentFallback action
WindowsPrompt the user to switch manually (nvm-windows / volta use); mark ⚠️ "not auto-aligned, confirm version manually" in the report
fnm / volta / asdfReplace nvm use with the equivalent (fnm use / volta pin / asdf local nodejs <version>)
Container / CINode version is fixed by the image; confirm the image version matches .nvmrc

Never pretend alignment happened — always state explicitly when auto-alignment was not performed.

9. Related

  • Origin: a real-world fix where a mobile project pinned .nvmrc = v14.21.3 while the host defaulted to v22, producing tsc/eslint false-passes under the higher version.
  • CI false-negative: a project whose only pin was .gitlab-ci.yml image: node:14 was misreported as undeclared after a zsh glob abort — §2 shell-safe probing + §7 probe_ci close that gap.
  • No-declaration path: when a project declares no Node version at all, §2 turns the user's answer into a persistent declaration file (§2.1 picks the type via §2.2's toolchain probe), closing the loop so the next session reads it on the first probe instead of asking again.
  • Referenced by: PDCA hosts and related tools via frontmatter dependencies (hosts are SoT; this skill does not maintain an integrator name list). test-suite-ensure may soft-reference alignment before tests.

Comments

Markdown supported