<NodeJSNpmSetup keywords="nodejs, npm, package-manager, nvmrc, engines, module-system, esm, commonjs, node-builtins, lock-file, sandbox-registry, corp-registry" type="infra-rules" ver="2.0">
  <Mission>
    Canonical rules for the Node.js runtime environment: package manager discipline, version alignment, module system, project layout.

    Goal: every Node.js project has a reproducible, explicit runtime setup — correct version pinning, one package manager, one module system. Same `npm ci` on the same `.nvmrc` produces the same environment everywhere.

    Scope is the Node.js runtime layer ONLY. Language tooling (TypeScript, linters, formatters) and application architecture live in separate directives layered on top.

    **Base axiom:** one package manager, one module system, one Node version per project. All three committed as files, not assumed.
  </Mission>

  <Belief_State>
    <Axiom id="AX_SINGLE_PACKAGE_MANAGER">
      One package manager per project: **npm only**. Forbidden alongside npm: yarn, pnpm, bun. `package-lock.json` is the lock; all other lock files must be absent and gitignored.

      Multiple managers produce conflicting lock files and non-deterministic installs. If `yarn.lock`, `pnpm-lock.yaml`, or `bun.lockb` exist in the repo → remove them; add to `.gitignore`.
    </Axiom>


    <Axiom id="AX_LOCK_FILE_DISCIPLINE">
      `package-lock.json` always committed. `node_modules/` never committed.

      `.nvmrc` + `package.json` + `package-lock.json` is the **reproducibility triad**. Without lock file, transitive deps re-resolve on every `npm install` → environment drift.

      Usage split: `npm install` locally (may update lock when `package.json` changes); `npm ci` in any automated context (reads lock as law, never updates).

      `.gitignore` MUST contain: `node_modules/`, `dist/`.
    </Axiom>

    <Axiom id="AX_EXACT_PINNING_FOR_TOOLING">
      Dev-tool packages MUST be pinned to exact versions. `npm install --save-exact` for packages affecting the build pipeline.

      A patch update to a build tool can silently change output. Exact pins guarantee the same tool version runs everywhere.

      Rule: runtime deps (published with the package) may use ranges. Dev deps affecting build output MUST be exact.

      Pattern: `"devDependencies": { "vite": "5.2.11" }`, not `"^5.2.0"` or `"~5.2"`.
    </Axiom>

    <Axiom id="AX_MODULE_SYSTEM_EXPLICIT">
      Module system declared once and never mixed. Mixing `import` and `require` in the same project causes non-deterministic resolution and conditional-exports mismatches. Choose at init; set `"type": "module"` (ESM) or omit/`"commonjs"` (CJS) in `package.json`. Do NOT add `.mjs`/`.cjs` files without an explicit dual-package strategy.
    </Axiom>

    <Axiom id="AX_NODE_BUILTINS_PREFIX">
      All Node.js built-in module imports use the `node:` prefix.

      `node:` prefix is unambiguous — cannot be shadowed by an npm package with the same name. Also signals intent: «this is a Node built-in, not a third-party dependency».

      Pattern: `import { readFile } from 'node:fs/promises'`, not `from 'fs/promises'`.

      Applies to all built-ins: `fs`, `path`, `http`, `crypto`, `events`, `stream`, `url`, `util`, `os`, `child_process`, `worker_threads`, `perf_hooks`, etc.
    </Axiom>

    <Axiom id="AX_NPM_AGENT_SANDBOX_REGISTRY">
      **Sandboxed agents force npm to use the public registry via inline POSIX env-prefix on every invocation. This bypasses any corporate `~/.npmrc` config that the sandbox cannot reach.**

      The problem: operator's `~/.npmrc` typically points at a corporate registry/proxy reachable only from inside corp network. A sandboxed agent doesn't have access → `npm install` hangs on `ENOTFOUND` / `EAI_AGAIN` or fails.

      **Solution — force public registry per command:**
      ```bash
      npm_config_registry=https://registry.npmjs.org/ npm install
      npm_config_registry=https://registry.npmjs.org/ npm view <pkg>
      npm_config_registry=https://registry.npmjs.org/ npm ci
      ```

      Why inline prefix:
      - Bypasses corp `~/.npmrc` without modifying it (operator's local setup intact).
      - No persistent state in shell env — nothing leaks between commands.
      - Self-documenting in logs — operator sees exactly which registry was used.
      - Sandbox-friendly — no dependency on inherited env from launch context.

      Mechanism: npm config priority — CLI flags > `npm_config_*` env vars > project `.npmrc` > `~/.npmrc` > `/etc/npmrc` > defaults. POSIX `VAR=value command` makes the variable visible to the spawned process only.

      **When corp registry IS required** (project legitimately depends on private corp packages):
      - Explicit deviation, declared at infrastructure scope discovery time, recorded in Decision Log.
      - Agent halts with `[!] BLOCKED` per `AX_BLOCKER_ESCALATION`; operator handles install in their full-network shell, OR explicit network-access grant for the sandbox.
      - Auth for corp registry is out of scope — environment concern, not agent's responsibility.

      **Forbidden:** `npm install` without env-prefix in sandbox (routes through corp `~/.npmrc`, hangs); modifying operator's `~/.npmrc` (breaks operator's other work); committing project `.npmrc` with corp registry URL; silent failure / partial install when registry unreachable (operator must see explicit error).
    </Axiom>

    <Axiom id="AX_PROJECT_LAYOUT">
      Standard layout:
      - `src/` — source files (JS or TS).
      - `dist/` — build output; generated, not committed.
      - `cli/` — CLI entry points if project exposes a CLI.
      - `scripts/` — internal dev/build scripts, not shipped.
      - `tests/` or co-located `*.test.*` — test files.

      Predictable layout reduces onboarding cost and enables tooling to infer build steps without configuration. Different layout allowed only when documented explicitly with stated reason.
    </Axiom>

    <Axiom id="AX_AI_FIRST_SCRIPTS_ONE_SHOT">
      **Every primary npm script MUST be one-shot — returns control deterministically. Watch / persistent / REPL modes live in separate `:watch` / `:dev` scripts.**

      AI agents (and CI runners) invoke scripts and wait for exit. A script defaulting to watch (e.g., `vitest`, `tsc --watch`, `next dev`) hangs the session — output streams forever, no exit code, no continuation. Agent has no way to distinguish «still working» from «ready, waiting for changes». Most common cause of stuck infra-setup tasks.

      | Script | Behavior | Used by |
      |---|---|---|
      | `test` | one-shot, exits | agents, CI, smoke tests |
      | `test:watch` | watch mode, persistent | human dev |
      | `lint` | one-shot, exits | agents, CI, pre-commit |
      | `lint:watch` | watch (rare) | human dev |
      | `build` | one-shot, exits | agents, CI |
      | `dev` | long-running dev server | human dev only |
      | `start` | long-running prod server | deployment runtime |
      | `start:check` | boot, smoke-ping, exit | agents (smoke-test deployment) |

      **Forbidden in `package.json` scripts:**
      - `"test": "vitest"` (defaults to watch in TTY) — must be `"vitest run"`.
      - `"test": "jest --watch"` — watch by default; must be `"jest"`.
      - `"test": "node --test --watch"` — must be `"node --test"`.
      - Any primary script that doesn't terminate without manual interrupt.

      For `dev`/`start`: inherently long-running; agents MUST NOT invoke directly during task-execution. Need server smoke-test → declare separate `:check` script that boots, pings, exits within timeout.

      Verification: before declaring infra-setup DONE, run each primary script and confirm exit (any code) within reasonable time. Hang >2 minutes without progress = violation.
    </Axiom>

    <Axiom id="AX_DEPENDENCY_ADDITION_CHECKLIST">
      Before adding any dependency, verify three things:
      1. **Node compatibility:** `npm view <pkg> engines` — supports version in `.nvmrc`?
      2. **Module system compatibility:** package ESM-only / CJS-only / dual? Matches project's `"type"`?
      3. **Pinning discipline:** dev tool → `--save-exact`; runtime dep → range acceptable.

      Skipping these introduces incompatibilities that surface as cryptic runtime errors, not install errors.
    </Axiom>
  </Belief_State>

  <Setup_Steps>
    <!-- Ordered actions for initial Node.js/npm project configuration.
         Axioms in Belief_State define the WHY and invariants; these steps define the HOW and sequence. -->
    <Step id="STEP_1_CREATE_NVMRC">
      Create `.nvmrc`. **One line, exact semver** — e.g. `22.14.0`. Not `22`, `22.x`, or `lts/iron`. Ranges and aliases produce unpredictable resolution across machines and time. Content must match `/^\d+\.\d+\.\d+$/`. Commit this file.
    </Step>
    <Step id="STEP_2_INIT_PACKAGE_JSON">
      Create or update `package.json`. Required fields:
      - `"type"`: `"module"` (ESM) or `"commonjs"` — choose one explicitly, never mix `import`/`require` within the project.
      - `"engines": { "node": ">=X.0.0 <Y.0.0" }` — aligned with `.nvmrc` major. E.g. `.nvmrc=22.14.0` → `">=22.0.0 <23.0.0"`. Misalignment is the primary cause of «works locally, fails in CI».
    </Step>
    <Step id="STEP_3_CREATE_NPMRC">
      Create `.npmrc`. **Required**: `registry=https://registry.npmjs.org/` (or operator-supplied registry URL — never infer silently; source from operator interactively if non-default). Commit this file. Auth tokens MUST NOT be committed — use environment variables or per-user `~/.npmrc`.
    </Step>
    <Step id="STEP_4_GITIGNORE_ENTRIES">
      Ensure `.gitignore` contains: `node_modules/` and `dist/`. Without this `package-lock.json` becomes the only lock-file guarantee — `node_modules/` committed bloats the repo with platform binaries.
    </Step>
    <Step id="STEP_5_INSTALL_DEPENDENCIES">
      Install dependencies. In a sandboxed agent environment prefix every `npm` invocation with inline env-prefix: `npm_config_registry=https://registry.npmjs.org/ npm install`. Dev tools that affect build output: `--save-exact`. Runtime deps: range acceptable. See AX_NPM_AGENT_SANDBOX_REGISTRY for full sandboxing rationale.
    </Step>
    <Step id="STEP_6_DECLARE_NPM_SCRIPTS">
      Add primary scripts to `package.json`. Rules: (a) only add a script if the tool is in `devDependencies`; (b) every primary script MUST be one-shot — exits with code, no watch mode (see AX_AI_FIRST_SCRIPTS_ONE_SHOT). Watch variants live in separate `:watch`/`:dev` scripts. Conventional names: `build`, `test`, `lint`, `format`, `type-check`, `clean`.
    </Step>
    <Step id="STEP_7_COMPOSE_CHECK_SCRIPT">
      Add `check` script to `package.json` — the single quality entry point called by all task tickets. Compose it from the `<CheckPhase>` values of every active rule in this scope, ordered by `CheckPhaseOrder` from `ai/directives/knowledge.xml` (typecheck → test → lint → format). Include only phases with at least one active rule. All sub-commands must be one-shot. Declare `check-command → npm run check` in infra-spec §Verification Commands.
    </Step>
    <Step id="STEP_8_VERIFY_REPRODUCIBILITY_TRIAD">
      Confirm three files committed: `.nvmrc`, `package.json`, `package-lock.json` (`npm ci` reads all three). Run HOOK_NVMRC_EXACT and HOOK_NPMRC_REGISTRY. Run each primary script — must exit within 2 minutes; hang = watch-mode violation.
    </Step>
  </Setup_Steps>

  <Definitions>
    <Definition id="DEF_REPRODUCIBILITY_TRIAD">Three committed files guaranteeing reproducible Node.js environment: `.nvmrc` (Node version), `package.json` (declared deps), `package-lock.json` (resolved dep tree). `npm ci` reads all three.</Definition>
    <Definition id="DEF_EXACT_PIN">A version string with no range operator: `5.2.11`, not `^5.2.0`, `~5.2`, or `5.x`.</Definition>
    <Definition id="DEF_ESM">ECMAScript Modules. Activated by `"type": "module"`. Syntax: `import`/`export`. Local imports require explicit extensions.</Definition>
    <Definition id="DEF_CJS">CommonJS. Default when `"type"` is absent or `"commonjs"`. Syntax: `require`/`module.exports`. Synchronous resolution.</Definition>
  </Definitions>

  <Code_Patterns>
    <Pattern id="PT_MINIMAL_PACKAGE_JSON">
      <Intent>Minimal valid `package.json`: explicit module system, engines aligned, exact dev-tool pins.</Intent>
      <Snippet language="json">
        ```json
        {
          "name": "my-project",
          "version": "1.0.0",
          "type": "module",
          "engines": { "node": ">=22.0.0 <23.0.0" },
          "scripts": {
            "build": "vite build",
            "test": "node --test"
          },
          "dependencies": { "some-lib": "^3.1.0" },
          "devDependencies": { "vite": "5.2.11" }
        }
        ```
      </Snippet>
      <Why>`"type": "module"` makes ESM explicit. `engines` bounds Node version. `vite` exact-pinned (build tool); `some-lib` uses range (runtime dep).</Why>
    </Pattern>

    <Pattern id="PT_NVMRC">
      <Intent>Correct `.nvmrc`: one line, exact semver.</Intent>
      <Snippet language="plaintext">22.14.0</Snippet>
      <Why>Single exact version. `nvm use` resolves unambiguously. No range, no alias, no comments.</Why>
    </Pattern>

    <Pattern id="PT_NPMRC">
      <Intent>`.npmrc` with explicit registry; credentials excluded.</Intent>
      <Snippet language="plaintext">
        registry=https://registry.npmjs.org/
        audit=true
        legacy-peer-deps=false
      </Snippet>
      <Why>Registry explicit. Audit on by default. No auth tokens — those go in env vars or per-user `~/.npmrc`.</Why>
    </Pattern>

    <Pattern id="PT_ESM_BUILTINS">
      <Intent>ESM imports of Node built-ins with `node:` prefix and explicit local extensions.</Intent>
      <Snippet language="javascript">
        ```js
        import { readFile } from 'node:fs/promises';
        import { createServer } from 'node:http';
        import { resolve } from 'node:path';

        import { parseConfig } from './config.js';   // explicit .js extension
        ```
      </Snippet>
      <Why>`node:` prefix prevents shadowing. Explicit `.js` extensions required in ESM for Node to resolve local files correctly.</Why>
    </Pattern>

    <Pattern id="PT_DEPENDENCY_ADDITION">
      <Intent>Correct workflow for adding a new package.</Intent>
      <Snippet language="bash">
        ```bash
        # 1 — Node compatibility
        npm view express engines

        # 2 — module system (look for "exports", "type")
        npm view express main exports

        # 3 — install with correct pinning
        npm install express                          # runtime dep — range ok
        npm install --save-exact --save-dev vite     # build tool — exact pin

        # 4 — commit both files
        git add package.json package-lock.json
        ```
      </Snippet>
      <Why>Verification before install prevents silent incompatibilities. Committing lock with package.json closes the reproducibility triad.</Why>
    </Pattern>
  </Code_Patterns>

  <Anti_Patterns>
    <Anti_Pattern id="AP_MULTIPLE_LOCK_FILES">
      <Bad>Repo has both `package-lock.json` AND `yarn.lock` AND/OR `pnpm-lock.yaml`.</Bad>
      <Why_Bad>Each manager resolves transitive deps differently. `npm ci` reads `package-lock.json`; developer running `yarn` writes `yarn.lock` with different versions. Builds diverge silently.</Why_Bad>
      <Good>Only `package-lock.json` tracked. Foreign locks deleted + added to `.gitignore`.</Good>
    </Anti_Pattern>

    <Anti_Pattern id="AP_NVMRC_ENGINES_MISMATCH">
      <Bad>`.nvmrc=22.14.0` + `"engines": { "node": ">=18.0.0 <20.0.0" }`.</Bad>
      <Why_Bad>`.nvmrc` installs Node 22; `engines` rejects Node 22. npm warns; CI may enforce constraint and fail. Local dev and CI run on different Node versions.</Why_Bad>
      <Good>`.nvmrc=22.14.0` + `"engines": { "node": ">=22.0.0 <23.0.0" }`.</Good>
    </Anti_Pattern>

    <Anti_Pattern id="AP_NVMRC_RANGE">
      <Bad>`.nvmrc` contains `22` (range) or `lts/iron` (alias).</Bad>
      <Why_Bad>`nvm use 22` resolves to whatever the latest 22.x is at install time — different on different machines and at different moments.</Why_Bad>
      <Good>`.nvmrc` contains `22.14.0` exact.</Good>
    </Anti_Pattern>

    <Anti_Pattern id="AP_NODE_MODULES_COMMITTED">
      <Bad>`git ls-files | grep '^node_modules/'` returns hits.</Bad>
      <Why_Bad>10–500 MB bloat. Platform-specific native binaries compiled on macOS won't run on Linux CI. PR diffs become unreadable.</Why_Bad>
      <Good>`.gitignore` includes `node_modules/`, `dist/`. `npm ci` restores from `package-lock.json` deterministically.</Good>
    </Anti_Pattern>

    <Anti_Pattern id="AP_MIXED_MODULE_SYSTEM">
      <Bad>`package.json` has `"type": "module"`, but `src/loader.js` uses `const fs = require('fs'); module.exports = …`.</Bad>
      <Why_Bad>`"type": "module"` makes all `.js` files ESM. Using `require` in ESM file throws `ReferenceError: require is not defined` at runtime.</Why_Bad>
      <Good>ESM project: `import { readFile } from 'node:fs/promises'; export { load };`.</Good>
    </Anti_Pattern>

    <Anti_Pattern id="AP_BUILTIN_WITHOUT_PREFIX">
      <Bad>`import { readFile } from 'fs/promises';` / `import { join } from 'path';`.</Bad>
      <Why_Bad>A third-party package named `fs` or `path` would shadow the built-in silently. Intent ambiguous: built-in or dependency?</Why_Bad>
      <Good>`import { readFile } from 'node:fs/promises';` / `import { join } from 'node:path';`.</Good>
    </Anti_Pattern>

    <Anti_Pattern id="AP_TOOL_RANGE_PIN">
      <Bad>`"devDependencies": { "vite": "^5.2.0", "prettier": "3.x" }`.</Bad>
      <Why_Bad>Semver-compatible update can silently change build output. CI installs 5.2.11 today, 5.2.12 next week — builds diverge.</Why_Bad>
      <Good>`"devDependencies": { "vite": "5.2.11", "prettier": "3.2.5" }`.</Good>
    </Anti_Pattern>

    <Anti_Pattern id="AP_NPMRC_ABSENT">
      <Bad>No `.npmrc` in the repository.</Bad>
      <Why_Bad>Each developer and CI runner resolves from whatever registry their global `~/.npmrc` points to. Private-registry users silently fall back to public or fail with 404.</Why_Bad>
      <Good>Committed `.npmrc` with `registry=https://registry.npmjs.org/`.</Good>
    </Anti_Pattern>
  </Anti_Patterns>

  <Verification_Hooks>
    <Hook id="HOOK_NVMRC_EXACT">
      <Purpose>`.nvmrc` exists, one line, semver-exact.</Purpose>
      <Command>test -f .nvmrc && [ "$(wc -l < .nvmrc)" -eq 1 ] && grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' .nvmrc && echo "OK"</Command>
      <Expected>Prints `OK`; exit 0.</Expected>
    </Hook>
    <Hook id="HOOK_ENGINES_PRESENT">
      <Purpose>`package.json` contains `engines.node`.</Purpose>
      <Command>node -e "const p=require('./package.json'); if(!p.engines?.node) process.exit(1); console.log('engines.node:', p.engines.node)"</Command>
      <Expected>Prints `engines.node: <range>`; exit 0.</Expected>
    </Hook>
    <Hook id="HOOK_LOCK_FILE_EXISTS">
      <Purpose>`package-lock.json` present.</Purpose>
      <Command>test -f package-lock.json && echo "OK"</Command>
      <Expected>Prints `OK`; exit 0.</Expected>
    </Hook>
    <Hook id="HOOK_NO_NODE_MODULES_TRACKED">
      <Purpose>`node_modules/` not tracked by git.</Purpose>
      <Command>git ls-files node_modules | head -1 | grep -q . && echo "FAIL: node_modules tracked" || echo "OK"</Command>
      <Expected>Prints `OK`; exit 0.</Expected>
    </Hook>
    <Hook id="HOOK_NO_FOREIGN_LOCK_FILES">
      <Purpose>No foreign lock files exist.</Purpose>
      <Command>for f in yarn.lock pnpm-lock.yaml bun.lockb; do test -f "$f" && echo "FAIL: $f present"; done; echo "OK"</Command>
      <Expected>Prints only `OK`; exit 0.</Expected>
    </Hook>
    <Hook id="HOOK_NPMRC_REGISTRY">
      <Purpose>`.npmrc` exists and declares registry.</Purpose>
      <Command>test -f .npmrc && grep -q 'registry=' .npmrc && echo "OK"</Command>
      <Expected>Prints `OK`; exit 0.</Expected>
    </Hook>
    <Hook id="HOOK_MODULE_SYSTEM_DECLARED">
      <Purpose>Report declared module system.</Purpose>
      <Command>node -e "const p=require('./package.json'); console.log('module system:', p.type || 'commonjs (default)')"</Command>
      <Expected>Prints `module system: module` or `module system: commonjs (default)`.</Expected>
    </Hook>
  </Verification_Hooks>

  <Reward_Criteria>
    ✅ `.nvmrc` exists, one line, exact semver. `package.json#engines.node` accepts the version in `.nvmrc`.
    ✅ Only `npm` used; `package-lock.json` committed; no foreign lock files. `node_modules/` gitignored.
    ✅ Module system declared once; not mixed. All Node built-in imports use `node:` prefix.
    ✅ Dev-tool packages exact-pinned. `.npmrc` committed with explicit `registry=` line.
    ✅ npm scripts call only tools in `devDependencies`. Primary scripts (`test`, `lint`, `build`, `type-check`) one-shot.
    ✅ `check` script exists in `package.json`. Composed of all active rule phases in `CheckPhaseOrder` (typecheck → test → lint → format). `check-command` declared in infra-spec §Verification Commands.
    ✅ Before adding a package: `engines` and module-system compatibility verified.
    ✅ Sandboxed agent forces public registry via inline env-prefix on every npm invocation.
    ✅ Corp registry required → agent halts `[!] BLOCKED`, defers to operator's full-network shell.

    ❌ `.nvmrc` missing / contains range / alias / multiple lines.
    ❌ `package.json#engines` absent or incompatible with `.nvmrc`.
    ❌ Foreign lock files present; `package-lock.json` missing or not committed.
    ❌ `node_modules/` committed or not in `.gitignore`.
    ❌ Module system mixed (`import` and `require` coexist in `.js`).
    ❌ Node built-in imported without `node:` prefix.
    ❌ Dev-tool pinned to range; `.npmrc` absent or no `registry=` line.
    ❌ npm script calls a tool not in `devDependencies`.
    ❌ `check` script missing from `package.json` when coding or testing rules are active in scope.
    ❌ `check` script omits a phase covered by an active rule (e.g., lint active but not in `check`).
    ❌ Any phase sub-command in `check` defaults to watch / persistent mode — agent invocations hang indefinitely.
    ❌ `npm install` without env-prefix in sandbox; modifying operator's `~/.npmrc`; committing project `.npmrc` with corp registry URL.
  </Reward_Criteria>
</NodeJSNpmSetup>
