fix(cli): pin tsx to the CLI's own tsconfig, and stop prescribing a build that was never consulted - #16643
Conversation
…uild that was never consulted `bin/run-dev.js` run from a cwd whose tsconfig maps a workspace package to its source loaded no command set at all, and blamed a build that was present and fresh. tsx reads the CWD's tsconfig, not the entry's, and applies its `paths` to every specifier it resolves -- the CLI's own included. The shim now asks its own loader whether any of this package's workspace dependencies is being resolved to TypeScript source, and re-execs once with `TSX_TSCONFIG_PATH` pinned to `packages/cli/tsconfig.json` when one is. The diagnostic keeps its value for every other cause of the same masking: it now asks WHERE the failing specifier resolved before prescribing a rebuild, and names the cwd tsconfig redirect when the build output was never consulted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
The tsconfig `paths` target is now a stub this suite writes into its own temp cwd, not a path into another package: `check:cross-package-test-inputs` refuses inputs wider than the package (they are invisible to the affected-subset filter and to turbo's cache), and a stub redirects exactly as well as real source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
`new URL()` was parsed outside the guard, so a resolution that is not a URL would have replaced the whole run with an error about the probe -- the rule the two reporters in this file are already written to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
📓 Docs Drift Check5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 36316f095bd58a6cdaf3bcda086f0903f420fe70 && git checkout 36316f095bd58a6cdaf3bcda086f0903f420fe70
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin bc0ac1d1f5775659e72ad35452c463443a71f1be ae8d60b6c3016975754042679dbc87f513755b55 && git checkout -B drift-repro bc0ac1d1f5775659e72ad35452c463443a71f1be && git merge --no-ff ae8d60b6c3016975754042679dbc87f513755b55
node scripts/docs-audit/affected-docs.mjs --json bc0ac1d1f5775659e72ad35452c463443a71f1be |
|
Hand docs sweep — negative finding, nothing edited. docs-drift listed nothing, and it says itself that is not a clean bill of health: it only surfaces pages that NAME a derived anchor. This change rewrites an operator-facing diagnostic string, which a page quotes as text rather than as a symbol — the one shape that check structurally cannot see. So the sweep was done by hand over the text. Searched across
Every hit and why it stands:
⛔ One thing the sweep did find, and it was worth the trip.
Generated by Claude Code |
Fixes #16547
bin/run-dev.jsrun from a cwd whose tsconfig maps a workspace package to itssource loaded no command set at all, and blamed a build that was present and
fresh. Both remedies the card names are here; the triage note is right that they
are not alternatives.
The mechanism — half CONFIRMED, half CORRECTED
The card's reading was a hypothesis. It was re-derived from the repro before any
code was written, and it is half wrong — recorded here because a fix built on
the wrong half would have been a different fix.
CONFIRMED — tsx honours the CWD's tsconfig, and it re-routes the CLI's own imports.
CORRECTED — it is NOT that the source subpath's export set differs from
dist.The card read the failure that way. The source has the export:
What breaks is the static link, and the reason is module format:
await import('@objectstack/spec/data')import { DATABASE_DRIVER_SELECTION_IDS } from '@objectstack/spec/data'SyntaxError: … does not provide an export named …packages/specandpackages/typesdeclare no"type": "module", so tsx loadstheir
.tssources as CommonJS. A static ESM named import can then bind onlythe names
cjs-module-lexerdetects statically, and the lexer does not follow thetwo-hop
export *chain (data/index.ts→./driver/index→./config-registry.zod) that publishes this one.packages/cliis"type": "module", which puts every one of its command modules on the failingside of that seam.
Isolated with a two-leg fixture whose only difference was that field:
The practical consequence for this PR: the fix pins the tsconfig, and both the
code and the tests assert on the resolution, never on an export set — an export
set is the red herring, and a suite written against it would pass for the wrong
reason.
What changed
1 — the cause, in
bin/run-dev.js. Beforerun(), the shim asks its ownloader whether any of this package's declared workspace dependencies is being
resolved to a TypeScript source file. No workspace
exportsmap targets one,so a
.tsanswer is positive evidence of a redirect rather than a guess. When oneis, it re-execs once with
TSX_TSCONFIG_PATHpinned topackages/cli/tsconfig.json,and says on stderr what it re-ran.
Why a re-exec and not an assignment: tsx parses its tsconfig in the loader's
initialize/globalPreload, both of which have already run by the time thisfile gets control. Measured — setting the variable from inside and re-resolving
answers the SOURCE path exactly as before. A second process or no pin at all.
Why the probe rather than an unconditional pin, measured on this box:
import.meta.resolve)lintthrough this shimThe probe answers
0 of 49from the repo root, exactly@objectstack/specfromexamples/app-multi-package, and exactly@objectstack/typesfrompackages/plugins/plugin-security— the two directories the card reproduced from,each naming its own package. Its error direction is the safe one: a dependency
that legitimately published a
.tsentry point costs one unnecessary re-exec,never a wrong answer.
2 — the wrong prescription, in
scripts/cli-unbuilt-workspace-lead.mjs. Theremedy used to be unconditional: classify, name the package, prescribe its build.
It now asks where the failing specifier resolved first, and when the answer is
a TypeScript source file it says the build output was never consulted and names
the redirect instead.
Deliberate deviation from the card's wording, stated so it can be argued with: the
card asks the diagnostic to check whether
packages/spec/distexists and isfresh. Existence and freshness are proxies, and both answer this case wrong —
distis present AND fresh in exactly the runs this exists to catch (the cardverified both blamed exports present in
distbefore filing). Resolution answersthe question that actually decides the remedy — was the build output consulted at
all — and needs no build-input hash, no stamp read, and no second definition of
"fresh" to keep in step with
check-dev-prereqs.mjs, which owns the only one thisrepo has. The resolution is injected by the shim rather than performed in the
module, so the module stays a decision over strings and the resolver is the very
one that produced the failure.
Red / green
cd examples/app-multi-package→tsx …/run-dev.js lint objectstack.config.ts --jsoncommand lint:… not foundcd packages/plugins/plugin-security→ same@objectstack/types/PLATFORM_OWNER_EMAIL_ENVReverse verification
Both legs mutate source that is executed directly (tsx for the shim, bare
nodefor the script) — neither subject resolves through a package
exportsinto adist/, soscripts/ablation-dist-preflight.mjshas no artifact to check and theon-disk proof is the marker count plus the blob hash.
Both legs ran under a
trap … EXIT INT TERMrestoring fromHEADby absolute path.Tests
test/unbuilt-workspace-lead.test.ts— 13 pass (6 existing, 7 new). The newcases carry their own controls: the same failure whose specifier did reach
build output keeps the rebuild remedy; a caller that asks no question gets the
pre-[finding]
bin/run-dev.jsrun from a cwd whose tsconfig maps@objectstack/specto source fails to load the command set and blames a missing spec build that is present #16547 answer; a probe that throws is no evidence; themissing-outputshape is left alone even when the probe would answer source.
test/run-dev-cwd-tsconfig-redirect.e2e.test.ts— 3 pass, a control set ratherthan one assertion (pin active / pin defeated / repo root). The redirecting cwd
is manufactured in a temp dir, and its
pathstarget is a stub this suitewrites — pointing at real workspace source would make the suite's inputs wider
than its package, which
check:cross-package-test-inputsrefuses, and would buynothing.
.e2eputs it in the nightly tier (OS_TEST_TIERS), the same run as its siblingrun-dev-unbuilt-workspace.e2e.test.ts, which covers the same shim.Changeset
skip-changeset, and the probe rather than the assumption.@objectstack/cli'spublished set is
files: ["dist","README.md","CHANGELOG.md"]plus thebintarget npm packs automatically. Against a built
packages/cli:The control is what makes the zero a reading: the same probe over the same list
plus one genuinely published path returns 1.
packages/cli/tsconfig.jsoncompilessrconly (rootDir: src,include: ["src"]), and this diff touches nopackages/cli/src/**; the threescripts/**files are owned by the private reporoot and are imported by no
packages/*/src. Nothing published moves.Clause ②
Re-derived from the delivered diff, not inherited: no
packages/spec/src/**, no*.zod.ts, no error-code ledger, no change to what a published contract acceptsor rejects.
no. No governed surface is touched either (docs/adr/**,.claude/**,skills/**,AGENTS.md,CLAUDE.md).验收备注
Found while measuring, deliberately NOT fixed here and NOT filed — recorded so the
seat can rule on them:
tsconfig.jsonfiles that carry thesepathsrules are a standing hazard for every tsx-honouring tool run from thosedirectories, not only this CLI. [finding] The source-resolution invariant has a third, unguarded axis: a tsconfig
pathsentry pointing at a sibling's src is a latent RUNTIME redirect for any tsx-honouring tool — and #8249's worklist is actively adding more of them #11094 already named it ("a latent runtimeredirect for any tsx-honouring tool") and has already landed. This PR removes the symptom
for one entry point; it does not remove the class. A general remedy would be a
policy change about how those rules are written, which is a decision, not a fix.
packages/specandpackages/typeshaving no"type": "module"is what turns the redirect into a link failure rather than aslower-but-working run. Changing it is a real ESM/CJS migration for two of the
most-depended-on packages in the tree, far outside this card, and the observation
is an observation rather than a reproducible defect on its own.
Generated by Claude Code