Skip to content

fix(cli): pin tsx to the CLI's own tsconfig, and stop prescribing a build that was never consulted - #16643

Merged
os-sales merged 3 commits into
mainfrom
claude/issue-16547-run-dev-cwd-tsconfig-redirect
Sep 7, 2026
Merged

fix(cli): pin tsx to the CLI's own tsconfig, and stop prescribing a build that was never consulted#16643
os-sales merged 3 commits into
mainfrom
claude/issue-16547-run-dev-cwd-tsconfig-redirect

Conversation

@claude

@claude claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #16547

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. 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.

$ cd examples/app-multi-package
$ node -e "console.log(import.meta.resolve('@objectstack/spec/data'))"   # under tsx
file:///…/packages/spec/src/data/index.ts        ← SOURCE, not the dist target
$ cd <repo root>                                  # the control, same command
file:///…/packages/spec/dist/data/index.mjs

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:

resolved= file:///…/packages/spec/src/data/index.ts
has DATABASE_DRIVER_SELECTION_IDS= true
export count= 470

What breaks is the static link, and the reason is module format:

probe, same specifier, same cwd result
await import('@objectstack/spec/data') 470 exports, the blamed name among them
import { DATABASE_DRIVER_SELECTION_IDS } from '@objectstack/spec/data' SyntaxError: … does not provide an export named …

packages/spec and packages/types declare no "type": "module", so tsx loads
their .ts sources as CommonJS. A static ESM named import can then bind only
the names cjs-module-lexer detects statically, and the lexer does not follow the
two-hop export * chain (data/index.ts./driver/index
./config-registry.zod) that publishes this one. packages/cli is
"type": "module", which puts every one of its command modules on the failing
side of that seam.

Isolated with a two-leg fixture whose only difference was that field:

LEG 1 — pkg WITHOUT "type": "module"   exit 1   SyntaxError: … does not provide an export named 'DEEP_NAME'
LEG 2 — same files, WITH it            exit 0   STATIC LINK OK, DEEP_NAME = 42

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. Before run(), the shim asks its own
loader whether any of this package's declared workspace dependencies is being
resolved to a TypeScript source file. No workspace exports map targets one,
so a .ts answer is positive evidence of a redirect rather than a guess. When one
is, it re-execs once with TSX_TSCONFIG_PATH pinned to packages/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 this
file 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:

cost
the 49-specifier resolve sweep ~72 ms (~1.35 ms per import.meta.resolve)
an end-to-end lint through this shim ~11.3 s — so the sweep is 0.6 %
an unconditional re-exec instead a whole second tsx bootstrap, ~550 ms per run

The probe answers 0 of 49 from the repo root, exactly @objectstack/spec from
examples/app-multi-package, and exactly @objectstack/types from
packages/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 .ts entry point costs one unnecessary re-exec,
never a wrong answer.

2 — the wrong prescription, in scripts/cli-unbuilt-workspace-lead.mjs. The
remedy 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/dist exists and is
fresh
. Existence and freshness are proxies, and both answer this case wrong
dist is present AND fresh in exactly the runs this exists to catch (the card
verified both blamed exports present in dist before filing). Resolution answers
the 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 this
repo 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

before after
cd examples/app-multi-packagetsx …/run-dev.js lint objectstack.config.ts --json exit 2, command lint:… not found exit 0, lints the config
cd packages/plugins/plugin-security → same exit 2, on @objectstack/types / PLATFORM_OWNER_EMAIL_ENV exit 0, 55 checks
CONTROL: repo root, same lint exit 0 exit 0, and 0 bytes on stderr — no probe hit, no re-exec

Reverse verification

Both legs mutate source that is executed directly (tsx for the shim, bare node
for the script) — neither subject resolves through a package exports into a
dist/, so scripts/ablation-dist-preflight.mjs has no artifact to check and the
on-disk proof is the marker count plus the blob hash.

HEAD blob packages/cli/bin/run-dev.js            = 02027cac98fbc6e95061c7a687afbd92098a37ae
HEAD blob scripts/cli-unbuilt-workspace-lead.mjs = 0dd942b012bf5a523647ec67b7e7065de8a2106a

LEG A1  pin removed        marker 1 -> 0, sentinel 0 -> 1, blob 713427ee != HEAD
        RESULT exit=2, 1 oclif "Error: command" line, "NOT A MISSING COMMAND"   ← the card's failure, back
LEG A2  restored           blob 02027cac == HEAD (identical), `git diff HEAD` empty
        RESULT exit=1, 0 oclif "Error: command" lines, the advisory line present

LEG B1  redirect branch disabled   sentinel 0 -> 1, blob 4a02c1a1 != HEAD
        RESULT "objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec"   ← the misdirection, back
LEG B2  restored           blob 0dd942b0 == HEAD (identical), `git diff HEAD` empty
        RESULT "objectstack: Fix: a tsconfig `paths` rule found from the current directory redirects …"

Both legs ran under a trap … EXIT INT TERM restoring from HEAD by absolute path.

Tests

  • test/unbuilt-workspace-lead.test.ts — 13 pass (6 existing, 7 new). The new
    cases 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.js run from a cwd whose tsconfig maps @objectstack/spec to 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; the missing-output
    shape is left alone even when the probe would answer source.
  • test/run-dev-cwd-tsconfig-redirect.e2e.test.ts — 3 pass, a control set rather
    than one assertion (pin active / pin defeated / repo root). The redirecting cwd
    is manufactured in a temp dir, and its paths target is a stub this suite
    writes — pointing at real workspace source would make the suite's inputs wider
    than its package, which check:cross-package-test-inputs refuses, and would buy
    nothing.
  • .e2e puts it in the nightly tier (OS_TEST_TIERS), the same run as its sibling
    run-dev-unbuilt-workspace.e2e.test.ts, which covers the same shim.

Changeset

skip-changeset, and the probe rather than the assumption. @objectstack/cli's
published set is files: ["dist","README.md","CHANGELOG.md"] plus the bin
target npm packs automatically. Against a built packages/cli:

published file set of @objectstack/cli: 497 files
bin/run-dev.js in published set?  False
bin/run.js     in published set?  True

PROBE   published files in this diff: []
CONTROL same probe, list + packages/cli/bin/run.js: ['packages/cli/bin/run.js']

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.json compiles
src only (rootDir: src, include: ["src"]), and this diff touches no
packages/cli/src/**; the three scripts/** files are owned by the private repo
root 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 accepts
or 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:


Generated by Claude Code

os-sales and others added 3 commits September 7, 2026 14:01
…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
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 7, 2026
@github-actions github-actions Bot added the size/l label Sep 7, 2026
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 22 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 bc0ac1d1f5775659e72ad35452c463443a71f1bepackageMentionDocs.

Which tree this was computed on

This run read content/docs from 36316f095bd58a6cdaf3bcda086f0903f420fe70 — the merge of head ae8d60b6c3016975754042679dbc87f513755b55 into base bc0ac1d1f5775659e72ad35452c463443a71f1be, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actions github-actions Bot added the tests label Sep 7, 2026
@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

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 content/**, docs/**, skills/**, examples/**, AGENTS.md, CLAUDE.md, CONTRIBUTING.md and every *.md / *.mdx:

  • the exact old remedy turbo run build --filter=@objectstack/spec
  • the masking text NOT A MISSING COMMAND and command … not found
  • run-dev / the dev entry in prose, and tsx …/run-dev invocation recipes
  • repo-root workaround advice — /from the (repo|repository) root|root-anchored/i
  • the redirect by name — tsx-honouring, 11094, latent runtime redirect

Every hit and why it stands:

hit verdict
packages/cli/CHANGELOG.md (3 passages quoting the old diagnostic) release history of what shipped then — rewriting it would make it false, not current
docs/qa/platform-checklist/RUNNER.md:250-262 closest call, and it survives: it is about the bare built binary on a tree with no packages/cli/dist, remedy pnpm build. That is the missing-output cause. bin/run.js runs under plain node — no tsx, no paths — so the new branch cannot fire there
docs/qa/platform-checklist/areas/cli.json:447,459 pnpm dev from the root boots a server; the cd examples/app-showcase && os test step uses the built os binary
examples/README.md:40 # From the repo root heads pnpm install / pnpm setup — first-time setup, not a workaround
content/docs/** every CLI page documents the published os binary, which this diff provably does not touch (0 of 497 published files)
skills/** zero hits

content/docs/releases/ untouched.

One thing the sweep did find, and it was worth the trip. scripts/check-i18n-bundles.mjs:827 asserts workspaceBuildFix('@objectstack/spec') still renders that exact string — which exposed two consumers of the module I edited (scripts/cli-build-prerequisite.mjs) that the derived gate union did not name. Rather than argue that an added field is additive, I ran both against this branch:

node scripts/check-i18n-bundles.mjs  --self-test   exit 0
node scripts/check-i18n-coverage.mjs --self-test   exit 0

workspaceBuildFix is untouched; only looksLikeStaleWorkspaceDist gained a specifier field.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review September 7, 2026 15:35
@os-sales
os-sales enabled auto-merge September 7, 2026 15:35
@os-sales
os-sales added this pull request to the merge queue Sep 7, 2026
Merged via the queue into main with commit 0f07b2c Sep 7, 2026
39 checks passed
@os-sales
os-sales deleted the claude/issue-16547-run-dev-cwd-tsconfig-redirect branch September 7, 2026 16:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

1 participant