Skip to content

The tool now runs on Node 16 instead of refusing below 20 - #265

Merged
qwerfunch merged 7 commits into
developfrom
feature/node-floor-cli-entry
Sep 11, 2026
Merged

qwerfunch merged 7 commits into
developfrom
feature/node-floor-cli-entry

Conversation

@qwerfunch

Copy link
Copy Markdown
Owner

The tool now runs on Node 16 instead of refusing below 20

2026-09-10 · feature/node-floor-cli-entry → develop

A global install on Node 16 died with a raw ERR_UNKNOWN_FILE_EXTENSION trace from inside Node's own loader. The entry file now carries an extension so it loads everywhere, and the supported floor moved from 20 down to 16 by removing the dependencies that reached above it.

Heads-up: the Node 20 floor was never real. Measured against running releases rather than read off a dependency declaration, the shipped bundle already ran on Node 18, and only four platform surfaces stood between it and Node 16 — three of them from one dependency used solely to spawn external commands. Because the engine ships as a single bundled file, that dependency's floor had silently become the whole tool's floor.

Fixed

▸ The entry file had no extension. The package declares the ESM module type, and Node's loader refuses an extensionless file in that scope. The crash happened before a single line of project code ran, which is why the message named neither the tool nor a version. Measured: Node 16 refuses the extensionless form, 18 and above accept it, and every release accepts an explicit module extension. Never a Windows defect — the same rejection reproduces on Linux.

▸ One dependency was setting the floor for everything. It reached for util.aborted, stream.getDefaultHighWaterMark and events.addAbortListener. Every external command now goes through one small runner over the platform's own child-process module, including the conformance harness, so that dependency leaves the tree entirely rather than sitting unused in the manifest. Four of its behaviours are reproduced deliberately, because a stage verdict changes otherwise:

  • Windows command resolution. The replaced library never called the raw platform spawn — it routed arguments through a portable resolver first. On Windows a bare npm is really npm.cmd, which the raw call cannot find, and spawning a .cmd without a shell throws outright on patched releases. The runner keeps that resolver. A shell would "work" while letting the command processor mangle an unquoted argument, which the architecture stage's exclusion pattern would hit immediately.
  • Final-newline stripping. The old library removed one trailing newline; the platform call does not. Call sites compare captured output against expected tokens.
  • The capture limit. The old default was 100 MB; the platform default is 1 MB and truncates silently past it. A verbose linter or test reporter exceeds 1 MB, and a truncated report parses as garbage — a passing suite read as a failure.
  • An absent exit status when the spawn never started. On Linux and macOS that falls out of the platform call. On Windows it does not: an unresolvable command is wrapped in the command processor, which exits 1, and the resolver then synthesizes the not-found error — so the raw result carries both. Keying on the error rather than the status keeps the shape identical everywhere, which is what the stages' missing-tool skip depends on.

▸ Our own two blockers. A single interactive prompt statically imported the promise flavour of readline, which exists only from Node 17, and that one import made the entire bundle unloadable on Node 16. It now uses the callback form. Built-in network fetch arrives in Node 18 and is used only by the direct model lanes; those lanes now name the missing capability and the release that provides it, and every other command keeps working. Refusing the whole tool for one optional path is the defect this work undoes.

Added

▸ A check that the floor cannot drift back. A script reads every platform-module import out of the built bundle and resolves each against the running release. No version table to maintain and no model involved. It runs in the build job and again on the floor release itself, so a dependency upgrade that reaches above the floor fails loudly instead of reaching a terminal. Its reach limit is documented in the script: it reads static imports, so a bundled CommonJS dependency reaching through require slips past — which is why the floor cell also runs real commands rather than printing a version string.

▸ CI cells for the two axes this bug lived on. The workflow was one job, one platform, one Node version. The entry job now packs the tarball and installs it through npm's own bin shim on Linux with Node 16, 20 and 22, on Windows with 22, and on Linux with Node 14 to prove the refusal still speaks below the floor. The Windows cell also runs the runner's own tests, since those drive real child processes and are the only place the Windows spawn path executes.

Verification

Through the real install path, not a direct file invocation:

node:14  →  cladding requires Node 16 or newer. This is Node 14.21.3.
            Upgrade Node, then run the command again.            exit=1
node:16  →  0.10.0                                               exit=0
node:18  →  0.10.0                                               exit=0
node:20  →  0.10.0                                               exit=0
node:22  →  0.10.0                                               exit=0

On Node 16 the originally reported command completes, and so does real work:

$ clad setup     # node:16 — completes, reports which hosts were wired
$ clad sync      # ✓ sync  306 features valid
$ clad status    # renders the feature × stage matrix
$ clad check --tier=pre-commit
  ✓ Type   ✓ Drift   ✓ Architecture   ✓ Secret

Full suite 3855 of 3855, conformance fixtures 33 of 33, typecheck and lint clean, strict pre-push gate green, and on the commit itself a rebuild produces no diff while the strict gate leaves the tree clean.

The branch also carries the 0.10.1 release preparation: eleven version sites, the six README record rows with the pins that hold them, and the release notes. Re-verified at that version through the install path — Node 14 refuses and exits 1, while 16, 18, 20, 22 and 24 all print 0.10.1.

Notes for the reviewer

▸ The conformance tests caught a real defect in the runner. Written in a separate context from the implementation, they found that every successful run was reporting itself as failed: the portable resolver assigns its error field to null rather than leaving it absent, and an identity check against undefined therefore saw a failure on every healthy spawn. No caller reads that field today, so nothing was visibly broken — which is exactly why a test rather than a reading found it.

▸ A skipped-conditionally test claims no criterion. The binding harvester reads titles only from plain test(...) calls, so a test.skipIf(...)('[covers:…]') title is invisible to it and the completion gate reports the criterion as unclaimed. Worth fixing in the harvester separately; here the affected test was expressed in plain form.

▸ On Node 16 the Lint stage reports a tool failure, because this project's own linter needs Node 18. That is a delegated toolchain reported as a stage finding rather than a crash, and it is the user's toolchain rather than the tool's floor. Contributors still need Node 20 for the test runner, which the contributor docs now say explicitly while the setup guide tells a user 16.

▸ Node 14 and below stay out of reach, deliberately. Node 14 additionally needs our own recursive-copy implementation and Node 12 two more of our own calls, for runtimes three years past end of life. Below the floor the refusal message is the honest answer.

▸ The conformance fixtures still pass, 33 of 33. Their git setup calls went through the removed dependency, which threw on failure; the replacement reports failure as data, so fixture setup now uses a small helper that throws loudly instead — a silent git init failure would build the wrong fixture and make the verdict meaningless.

▸ One pinned measurement moved: the design evidence page records the byte total of the routed design set and a test holds it to the real figure, so editing design documents refreshed it. The surrounding figures in that paragraph are a dated snapshot and were already behind before this change.

qwerfunch and others added 7 commits September 10, 2026 16:18
…clearly

A global install on Node 16 died with a raw ERR_UNKNOWN_FILE_EXTENSION trace
from inside Node's ESM loader. Two defects produced it:

- The bin entry had no file extension. Under "type": "module" Node's ESM
  loader refuses an extensionless file, so the crash happened before any
  project code ran and nothing could report it. Measured in containers: Node
  16 refuses the extensionless form, 18/20/22 accept it, and every release
  accepts `.mjs`. Not a platform defect — it reproduces on Linux too.
- No Node floor was declared or enforced. There was no `engines` field, so
  npm installed silently on an end-of-life runtime, and no `process.version`
  check anywhere.

bin/clad -> bin/clad.mjs, with the floor check ahead of the bundle import (the
bundle is what would otherwise throw, so a later check would never run). The
floor is 20: bundled commander declares >=20 and the esbuild target is node20.
`engines` is declared too, but it only warns — the runtime refusal is what the
user sees.

This adds no support for Node 16. It turns a crash into an instruction.

CI gains a smoke-only entry job that installs the packed tarball through npm's
own bin shim on ubuntu 20/22, windows 22, and ubuntu 18 for the refusal — the
two axes (platform, below-floor Node) that were unexercised.

Verified through the real install path: Node 16 and 18 print the floor message
and exit 1; 20 and 22 print the version. The originally reported `clad setup`
on Node 16 now prints the same message.

F-5fc112ad

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The previous commit declared a floor of Node 20 on the strength of a
dependency's `engines` field. Measured against running releases, that floor
was never real: the shipped bundle already ran on Node 18, and exactly four
platform surfaces stood between it and Node 16 —

  execa  → node:util aborted
  execa  → node:stream getDefaultHighWaterMark
  execa  → node:events addAbortListener
  ours   → node:readline/promises

Because the engine ships as one bundled file, execa's floor had silently
become the whole tool's floor. A user could not run `clad --version` on
Node 16 for want of a spawn helper.

So the floor drops by removing what reached above it, not by relaxing a
number. `src/core/run-sync.ts` replaces execaSync at all thirteen call sites
and in the conformance harness, so execa leaves the tree entirely. Four
behaviours are reproduced deliberately:

  - cross-spawn keeps the Windows `.cmd` resolution execa was delegating; a
    raw spawn cannot find `npm`, and `shell: true` would let cmd.exe mangle
    the architecture stage's exclusion regex
  - the exit status is absent whenever the spawn never started, on every
    platform — Windows reports 1 through the command processor AND
    synthesizes ENOENT, so the error, not the status, decides
  - one trailing newline is stripped from each captured stream
  - the capture limit stays 100 MB, against the platform's 1 MB that
    truncates silently

Our own blockers: one interactive prompt statically imported the promise
flavour of readline, which exists from Node 17 and made the whole bundle
unloadable on 16 — now the callback form. Built-in fetch arrives in Node 18
and only the direct model lanes use it, so those name the missing capability
and the release that provides it while every other command keeps working.

scripts/check-node-surface.mjs is the regression guard: it reads every
platform-module import out of the bundle and resolves each against the
running release, so a dependency upgrade that reaches above the floor fails
loudly. CI runs it in the build job and again on the floor release, and the
entry matrix becomes Linux 16/20/22 plus Windows 22, with Node 14 proving
the refusal still speaks below the floor. The Windows cell runs the runner's
own tests, the only place that spawn path executes.

Verified through the real install path: 14 refuses and exits 1; 16, 18, 20
and 22 print the version. On Node 16 the originally reported `clad setup`
completes, and `sync` (306 entries), `status` and the gate all run.

The conformance tests, authored in a separate context, caught a real defect:
cross-spawn assigns its error field to null on a healthy run, so an identity
check against undefined reported every success as failed.

Node 14 and below stay out of reach deliberately — 14 needs our own
recursive copy and 12 two more of our own calls, for runtimes three years
past end of life.

F-203a3114

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Eleven version sites plus the six README record rows and the honesty pins
that hold them. Release notes describe the Node floor drop in user terms: a
global install on Node 16 used to die inside Node's module loader before
printing anything, and the floor stood at 20 only because a bundled
dependency declared it.

Verified at this version through the real install path — Node 14 refuses
with one sentence and exits 1; 16, 18, 20, 22 and 24 print 0.10.1. Full
suite 3855/3855, conformance 33/33 at L4, strict pre-push gate green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`test.skipIf(cond)` with a true condition removes a test from COLLECTION, not
just from the run. Two of the new runtime tests keyed on `dist/clad.js`, which
is gitignored, so a fresh clone collected 3853 where the committed claim said
3855. The public count guard then refused the build, and because `npm ci` runs
it through `prepare`, every CI job failed at its first step.

The bundle is resolved once — `dist/clad.js` when present, else the committed
`plugins/claude-code/dist/clad.js` mirror, which exists in any checkout — and
every test is a plain `test(...)`, with existence asserted inside the test so a
missing bundle fails loudly instead of disappearing from the count. The npm
resolution case loses its PATH gate for the same reason and keeps its meaning:
an unresolvable npm fails with the portable-resolution message.

Collection is now identical with and without build output: 3855 either way,
verified in a clone carrying no dist/.

F-203a3114

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The Windows cell failed, and the cause was not Windows-specific code: each
entry cell ran `npm ci && npm run build`, and the build validates the public
test count. That count legitimately varies by platform — one transaction test
cannot run on Windows — so Windows collects 3854 against a claim of 3855 and
the build refuses before anything is exercised.

A per-cell build was the wrong shape anyway. One pack job now builds on a
platform and release that can run the full toolchain, uploads the archive, and
every cell installs the SAME bytes — which is a stronger claim than each cell
building its own. The surface check reads the shipped bundle out of the
unpacked archive rather than a local build.

The Windows cell still needs dev dependencies for the runner tests, installed
with --ignore-scripts so the prepare hook does not drag the count guard back
in. That guard keeps running where it belongs: the verify job, on one known
platform.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`npm pack --pack-destination` does not create its destination, so the pack job
failed with ENOENT after the build had already succeeded. Reproduced in a
clean clone, fixed with the mkdir, and re-verified there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`npm install -g archive/<file>.tgz` is not a path to npm — an argument with a
slash and no leading `./` is read as a GitHub owner/repo shorthand, so every
entry cell tried `ssh://git@github.com/archive/cladding-0.10.1.tgz.git` and
exited 128. My local runs used absolute paths and sailed past it.

Reproduced in a container both ways: the bare form spawns git, the `./` form
installs and prints the version. The unpack step takes the same prefix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@qwerfunch
qwerfunch merged commit 3eb4ef2 into develop Sep 11, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant