Skip to content

fix(deps): patch tsx 4.23.15 so oclif registers it on every supported Node, re-arming the reroute probe CONTROL leg - #21356

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21308-tsx-esm-api-register
Oct 2, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21308-tsx-esm-api-register

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21308

Clause-②: no

What this changes

tsx 4.23.15 moved its ESM-API registration out of a dist/ chunk and into dist/esm/api/index.cjs, two directories deeper. It did not rebase three file-relative specifiers. On a Node without tsx's module.registerHooks path, the CommonJS build of tsx/esm/api then calls module.register() with a hooks URL that names dist/esm/api/esm/index.mjs, a file that does not exist. @oclif/core's ts-path is the caller in this repo. It swallows the error, logs Could not find tsx, and stays on dist/.

This PR adds a pnpm patch of tsx@4.23.15 that points the three specifiers back at the files 4.23.14 resolved. No version moves.

  • patches/tsx@4.23.15.patch: new, written by pnpm patch / pnpm patch-commit.
  • pnpm-workspace.yaml: the patchedDependencies entry and its note, next to the existing tsup@8.5.1 entry.
  • pnpm-lock.yaml: regenerated by pnpm install, not edited by hand.
  • packages/cli/test/published-entry-node-env-source-reroute.test.ts: a docblock pointer only. No leg, assertion or bound changes.

Measured

H0: CI's reading of the CONTROL leg is NOT MEASURED (log hosts unreachable)

Both log hosts refused this session at CONNECT (gateway answered 403):

  • productionresultssa14.blob.core.windows.net, the job logs of all six Test Core shards of run 36964705223 on 6091136;
  • results-receiver.actions.githubusercontent.com, the run log zip.

node scripts/pm/ci-failure.mjs --run 36964705223 exited 2 with job log NOT RETRIEVED.

What was measured instead is the mechanism that decides it. tsx 4.23.12, 4.23.14 and 4.23.15 all take the synchronous registerHooks path only on Node 22.22.3+, 24.11.1+, 25.1.0+ or 26+ (version table [[22,22,3],[24,11,1],[25,1,0],[26,0,0]]). That path never reaches the broken specifier. Node 22.22.3 was released on 2026-05-13. CI's setup-node asks for node-version: '22', and the dev containers run v22.22.0. Same tree (6091136), unpatched tsx:

Node published-entry-node-env-source-reroute.test.ts
v22.22.0 1 failed (CONTROL), 4 passed
v22.22.3 (official tarball, sha256 checked against SHASUMS256.txt) 5 passed

So CI arms the leg if its runner resolved '22' to 22.22.3 or later. The runner's actual version is in the log this session could not read.

H1: confirmed, with a version bound

DEBUG=oclif:config:ts-path on v22.22.0, CONTROL preload, the test's own env (no NODE_PATH):

Could not find tsx. Skipping tsx registration for .../packages/cli.
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../tsx/dist/esm/api/esm/index.mjs' imported from .../tsx/dist/esm/api/index.cjs

Next it logs Cannot find module 'ts-node', and tsPath returns the dist/ path. In the tarballs, 4.23.12, 4.23.13 and 4.23.14 keep the register() call in dist/register-*.cjs, where it resolves. 4.23.15 inlines it into dist/esm/api/index.cjs, which grows from 1,298 to 16,770 bytes. The .mjs build of tsx/esm/api is unaffected. oclif reaches the .cjs build because it locates the module with require.resolve.

H2: holds

npm view tsx dist-tags gives latest as 4.23.15 (2026-09-20). There is no newer release, so no UP move (remedy d) exists.

A second casualty of the same defect

On v22.22.0 with unpatched tsx, bin/run-dev.js also lands on dist/commands. That file is the CLI's SOURCE entry, the one runServe and the other spawning tests launch (104 test files under packages/cli mention it). tsx bin/run-dev.js --version under DEBUG=oclif:config:ts-path logs Could not find tsx and never Found source directory. With the patch it logs Found source directory for .../dist/commands at .../src/commands. So on such a Node, source-entry tests have measured the build output rather than src/ since #21162. The same patch fixes this with nothing added.

Why (b), and not (a) or (c)

(a) pins tsx down to 4.23.14. That is a DOWN move and needs a ruling, and measurement does not make it the only sound remedy. Not taken.

(c) reworks the CONTROL leg. To arm without oclif's own tsx registration, the child would need a test-only change to how it resolves tsx/esm/api: a Module._resolveFilename shim in the preload, or --conditions=import on the child. That fails the second acceptance criterion as measured. The absence legs do not load the preload, so on v22.22.0 with unpatched tsx, deleting the declaration leaves them green (row 4 below). Arming them too would put the shim in every leg, and then every leg measures a resolution no real install has.

(b) patches the component that fails. The test's behaviour is unchanged, and both acceptance criteria hold on v22.22.0 and on v22.22.3.

Four axes:

  • Real business need: measured. Two consumers break on Node below 22.22.3. One is this probe's CONTROL leg. The other is the source entry bin/run-dev.js, which is the larger one, because the CLI's spawning tests silently ran dist/. The published CLI is not reached: bin/run.js sets settings.enableAutoTranspile = false, so oclif never registers tsx there, and nothing in packages/** source imports tsx/esm/api.
  • Long-term soundness: the patch is a workaround for an upstream packaging defect, and it has a cost. It replaces one 16 KB minified line, which nobody can review by eye. The token comparison below is how to review it. The key names the exact version, so a tsx bump fails pnpm install (ERR_PNPM_UNUSED_PATCH, the same rule the tsup@8.5.1 entry records), and the patch cannot outlive its reason silently. Retire it when a tsx release resolves the three paths itself, or when the toolchain's Node floor reaches 22.22.3. (c) would carry a test shim with no such tripwire. After an upstream fix it would become dead code that still makes every leg diverge from a real install.
  • Making AI mistakes harder: (b) gives the probe one meaning in every environment. Today the same file is green in CI and red in the dev containers, and three devs reported that red in passing, without a card, because it read as "environment". (c) adds a second resolution dialect inside a test fixture, which is the consumer-side tolerance that contract-first rules out.
  • Startup focus: no new gate, no new dependency, no version movement. Three tracked files, plus the lockfile the tooling wrote.

CONTROL leg and reverse verification (Node v22.22.0 unless stated)

tree tsx declaration in bin/run.js development / test legs CONTROL (development, neutralised)
6091136 (main) unpatched present pass FAIL: output is only @objectstack/cli/17.6.0 linux-x64 node-v22.22.0
cc0c05b patched present pass pass: card signature present, exit non-zero
cc0c05b patched deleted FAIL x2: expected '(node:...) [MODULE_NOT_FOUND] Warni...' not to contain 'Cannot find module './registry'' pass
cc0c05b, packages/cli/node_modules/tsx linked to the pristine 4.23.15 unpatched deleted pass (vacuous: 1 failed, 4 passed) FAIL
8569d5f (final) patched present pass pass (5 passed)
8569d5f, Node v22.22.3 patched present pass pass (5 passed)

scripts/ablation-replace.mjs --delete removed the declaration (anchor 1 to 0, blob 569e6b6f to 40a368ce). The same tool restored it and showed the blob equal to HEAD's, with git diff HEAD empty. The tsx link swap ran under an EXIT/INT/TERM trap, and readlink confirmed the restore. bin/run.js runs unbuilt, so no dist/ step is involved.

Reviewing the patch

The patch replaces one minified line. Review it with this comparison, which splits the pristine and the patched file on commas and prints every token that differs:

npm pack tsx@4.23.15 && tar xzf tsx-4.23.15.tgz
node -e 'const fs=require("fs");const a=fs.readFileSync(process.argv[1],"utf8").split(","),b=fs.readFileSync(process.argv[2],"utf8").split(",");let n=0;for(let i=0;i!==a.length;i++)if(a[i]!==b[i]){n++;console.log("-",a[i].slice(-60));console.log("+",b[i].slice(-60))}console.log(n,"of",a.length,"differ")' package/dist/esm/api/index.cjs node_modules/.pnpm/tsx@4.23.15_patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6/node_modules/tsx/dist/esm/api/index.cjs

Output:

- se=[new URL("loader.mjs"
+ se=[new URL("../../loader.mjs"
- new URL("esm/index.mjs"
+ new URL("../index.mjs"
- essageChannel;R.register(`./esm/index.mjs?${_.randomUUID()}`
+ e.MessageChannel;R.register(`../index.mjs?${_.randomUUID()}`
3 of 514 comma-separated tokens differ

The register() specifier is the one that breaks things. The two new URL(...) entries feed tsx's isTsxImport set and carry the same wrong base. They are fixed in the same patch because they are the same defect, in the same file, with the same mechanical change. Both rebased targets exist on disk (dist/esm/index.mjs, dist/loader.mjs). No test here reaches those two entries, because no child in this suite passes a TypeScript preload ahead of an absolute tsx loader path.

Lockfile

pnpm install regenerated it. With the tsx patch hash normalised away, every removed line equals an added line, except for the three new patchedDependencies lines. 0 versions moved, 0 DOWN, 0 packages added or removed. Ten snapshot keys change only by the hash suffix: tsx itself, and the peer suffix of tsup, postcss-load-config, fumadocs-mdx, vite x2, vitest x2 and @vitest/mocker x2. pnpm install --frozen-lockfile passes on the final head.

Verification

skip-changeset: nothing published moves. @objectstack/cli ships only dist, README.md and CHANGELOG.md, so the test file is not published. patches/, pnpm-workspace.yaml and pnpm-lock.yaml are root files no package ships. The CLI's dependency range on tsx is unchanged.

Acceptance notes

  • The dev containers run Node v22.22.0, while CI's setup-node floats on '22'. That gap is why the same file read red in three dev containers and green in Test Core. Observation only, no card. Carrier: none.
  • Upstream: whether tsx already has an issue or fix in flight was not read, because this session cannot reach privatenumber/tsx through the API. The retirement condition is in the pnpm-workspace.yaml note.
  • Published reach: @objectstack/cli depends on tsx ^4.23.15 at runtime, so a customer on Node 22.0 to 22.22.2 installs the defective build. No published code path calls require('tsx/esm/api'), so no public entry point reaches it. Measured by a grep of packages/** source and by the published entry's enableAutoTranspile = false.
  • packages/cli/README.md and packages/cli/package.json belong to cli README: documents -h / -v short flags that exit 2, and says there is no os plugin command group while os plugin build|sign|publish is registered #21310 and are not touched.

Generated by Claude Code

claude added 6 commits October 2, 2026 05:12
…ves its own hooks module again

tsx 4.23.15 moved its ESM-API registration out of a dist/ chunk and into
dist/esm/api/index.cjs without rebasing three file-relative specifiers.
The module.register() hooks URL now names dist/esm/api/esm/index.mjs,
which does not exist. A CommonJS caller of tsx/esm/api therefore gets
ERR_MODULE_NOT_FOUND on every Node that lacks tsx's registerHooks path
(below 22.22.3 on the 22 line). @oclif/core's ts-path is that caller: it
logs "Could not find tsx" and stays on dist/. So the CONTROL leg of
published-entry-node-env-source-reroute could not arm on such a Node, and
the absence legs beside it passed with the guard they pin deleted.

The patch points the three specifiers back at the files 4.23.14 resolved.
The lockfile moves no version; only the tsx patch hash is added.

Claude-Session: https://claude.ai/code/session_018gA1pE6eJtwHhqx72G8U9X
Co-authored-by: Claude <noreply@anthropic.com>
…nds on

A CONTROL red whose output is only the version line is a tsx
registration failure inside oclif's ts-path, not a fixture or guard
defect. The docblock says so and points at the tsx patch note in
pnpm-workspace.yaml.

Claude-Session: https://claude.ai/code/session_018gA1pE6eJtwHhqx72G8U9X
Co-authored-by: Claude <noreply@anthropic.com>
…n src/

On a Node without tsx's registerHooks path, unpatched tsx 4.23.15 also
sends packages/cli/bin/run-dev.js to dist/commands. Measured with
--version under DEBUG=oclif:config:ts-path on Node 22.22.0: "Could not
find tsx" without the patch, src/commands with it.

Claude-Session: https://claude.ai/code/session_018gA1pE6eJtwHhqx72G8U9X
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/s dependencies Pull requests that update a dependency file tests labels Oct 2, 2026
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Oct 2, 2026
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6c5bef5f4ee5b3c5ed497e785b4d24f6b2649bf5 → packageMentionDocs.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Review: ACCEPT, PR #21356 (head 8569d5f307), card #21308

Reviewed 2026-10-02T06:48Z by the PM seat (session_018gA1pE6eJtwHhqx72G8U9X) against GitHub, the branch and the npm tarballs.

Verified at the head:

  • Shape and scope. The PR is a draft against main, first line Fixes #21308, assigned to os-bill, labelled skip-changeset, and subscribed to the dispatching session. It has 4 files, +181/−137: patches/tsx@4.23.15.patch, pnpm-workspace.yaml, pnpm-lock.yaml, and a docblock pointer in published-entry-node-env-source-reroute.test.ts. check-governed-merges reads NOT governed. No packages/cli/README.md or packages/cli/package.json (cli README: documents -h / -v short flags that exit 2, and says there is no os plugin command group while os plugin build|sign|publish is registered #21310's surface).

  • The patch is exactly three specifiers. A character-level diff of the one-line dist/esm/api/index.cjs finds three edit regions and nothing else:

    • register('./esm/index.mjs') → '../index.mjs';
    • new URL("esm/index.mjs") → "../index.mjs";
    • new URL("loader.mjs") → "../../loader.mjs".

    From dist/esm/api/ these resolve to dist/esm/index.mjs and dist/loader.mjs. Those are the files 4.23.14 used, and they exist in the 4.23.15 tarball.

  • Remedy (b), with no version move. pnpm-workspace.yaml adds one exact-version patchedDependencies key, tsx@4.23.15, with a note naming the defect, both casualties, the Node gate and the retirement condition. The exact key makes a future tsx bump fail pnpm install until the patch is re-decided. Remedy (a) was not taken, so no maintainer ruling is owed.

  • Lockfile against the merge base 6c5bef5f4. 0 name@version pairs change and 0 go down. Every changed line only adds (patch_hash=…) to tsx and its 10 dependent snapshot keys, the footprint pnpm patch leaves. The os-dev reports pnpm install --frozen-lockfile passing at the head.

  • The probe is armed again. Per the os-dev's measurements, the CONTROL leg goes from red to 5/5 passed on v22.22.0 and stays 5/5 on v22.22.3. Reverse verification shows both directions:

    • with the declaration deleted (ablation-replace, restore proven), the patched tree turns the absence legs red;
    • pristine tsx under the same deletion keeps them vacuously green, which is the defect the card names.
  • Why CI stayed green. tsx takes its registerHooks path on Node 22.22.3 and later, which never reaches the broken specifier. Unpatched main is red on v22.22.0, the dev containers, and green on v22.22.3. CI's own Node resolution was not readable from this session. The patch gives the probe one meaning on every supported Node.

  • Second casualty, fixed by the same patch. On Node below 22.22.3, bin/run-dev.js (the source entry that the CLI's spawning tests run) silently loaded dist/commands instead of src/commands.

  • Ships nowhere. @objectstack/cli publishes dist, README.md and CHANGELOG.md; bin/run.js disables tsx auto-transpile; and no packages/** source imports tsx/esm/api. So skip-changeset is right.

Contract review: not owed. The diff touches none of the five contract-review surfaces: no content/docs, no .changeset prose, no spec source, no governed text.

Landing: flipped to ready and queued once every check on this head is green, or a skip on the expected-skips roster.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 2, 2026 07:10
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 2, 2026 07:11
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 2, 2026
Merged via the queue into main with commit 6961245 Oct 2, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21308-tsx-esm-api-register branch October 2, 2026 07:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

2 participants