The tool now runs on Node 16 instead of refusing below 20 - #265
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The tool now runs on Node 16 instead of refusing below 20
2026-09-10 · feature/node-floor-cli-entry → developA global install on Node 16 died with a raw
ERR_UNKNOWN_FILE_EXTENSIONtrace 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.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.getDefaultHighWaterMarkandevents.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:npmis reallynpm.cmd, which the raw call cannot find, and spawning a.cmdwithout 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.▸ 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
requireslips 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:
On Node 16 the originally reported command completes, and so does real work:
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
nullrather than leaving it absent, and an identity check againstundefinedtherefore 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 atest.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 initfailure 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.