ReviewOS

stacks/stx

stx typecheck and stx codemod are dead on arrival in every published build: await import('bun') minifies to the undefined identifier awaitPromise

#1896
Closed glennmichael123 opened this 14 days ago · 2 comments
14 days ago

stx typecheck and stx codemod both exit immediately in any installed build, including the published tarball:

$ bun node_modules/@stacksjs/stx/dist/cli.js codemod
development.ERROR: Unhandled Rejection: ReferenceError: awaitPromise is not defined

$ bun node_modules/@stacksjs/stx/dist/cli.js typecheck
development.ERROR: Unhandled Rejection: ReferenceError: awaitPromise is not defined

Neither command can do anything at all — it dies before reading a single file.

This is the published artifact, not a local build

I checked, because my node_modules/@stacksjs/stx is symlinked to a clone and a locally-built dist would have proved nothing:

$ npm pack @stacksjs/stx@0.2.170
$ tar xzf stacksjs-stx-0.2.170.tgz
$ grep -c "awaitPromise" package/dist/cli.js
2
$ grep -o ".\{80\}awaitPromise.\{80\}" package/dist/cli.js
…formatCodemodFindings:G}=await Promise.resolve().then(() => (qb(),zb)),{Glob:K}=awaitPromise.resolve(globalThis.Bun),W=…

The minified identifiers differ from my local build (qb(),zb vs Vb(),Fb), so these are two independent builds that both produced the defect. Everyone who installs 0.2.170 gets it.

Root cause: await import('bun') after a comma

Both sites are the same source construct — a dynamic import of the bun builtin, which the bundler rewrites to await Promise.resolve(globalThis.Bun):

// bin/cli.ts:1795 (typecheck) and :1852 (codemod)
const { formatTypecheckDiagnostics, typecheckStxFiles } = await import('../src/typecheck')
const { Glob } = await import('bun')

The two consts merge into one comma-separated let list, and the space between await and Promise is dropped on the second one, fusing them into the identifier awaitPromise:

let {…}=await Promise.resolve().then(() => (qb(),zb)),   // ✅ survives
    {Glob:K}=awaitPromise.resolve(globalThis.Bun)        // ❌ fused

The first await Promise in that very same statement is fine, and there are 58 correct await Promise occurrences elsewhere in the same file — so this is not a blanket minifier failure. It is specific to await Promise.resolve(<identifier>) landing after a comma in a merged declaration list.

The correlation is exact:

count
await import('bun') in bin/cli.ts2 (lines 1795, 1852)
awaitPromise in published dist/cli.js2
commands that throw2 (typecheck, codemod)

Every other command I tried (doctor, a11y, status, --help) works. dist/cli.js is the only affected file in the tarball.

Why this matters more than a two-command outage

These are the two features shipped this month, and both are unusable in every released build:

  • codemod is what #1843 shipped (a83305beb2) to remediate the adoption debt that issue documented — the tool written to fix "apps hand-roll what stx already exports" cannot be run by an app.
  • typecheck is what #1889 shipped (d426fb19d7) to type-check template expressions.

Fix

Bun is a runtime global; the dynamic import buys nothing. Any of these removes the construct:

const { Glob } = globalThis.Bun          // simplest
import { Glob } from 'bun'               // static, top of file
const bunMod = await import('bun'); const { Glob } = bunMod   // unmerged

Why nothing caught it

The test suite exercises src, so it never evaluates the emitted dist/cli.js — the defect only exists after bundling. A smoke test that shells out to the built CLI for each registered command (--help alone would have caught both, had the actions been reached) closes this class. It is the same trap as the stale-dist problem in #1891, one layer further out: there, tests read a stale artifact; here, they never read the artifact at all.

Found while trying to run stx codemod against a production app to act on #1843.

14 days ago

Correction: this is a Bun bundler bug, and stx is only a victim of it

I attributed this above to await Promise landing after a comma in a merged declaration list. That was wrong. The trigger is far smaller, and the culprit is not stx.

Three lines reproduce it with no stx involved at all:

// m.ts
export async function run() {
  const { Glob } = await import('bun')
  return typeof Glob
}
$ bun build ./m.ts --target=bun --minify --outfile=m.js
$ cat m.js
// @bun
var n=import.meta.require;async function r(){let{Glob:t}=awaitPromise.resolve(globalThis.Bun);return typeof t}export{r as run};

await import('bun') is rewritten to await Promise.resolve(globalThis.Bun), and the minifier's printer drops the mandatory space after await, fusing it into the identifier awaitPromise.

The trigger is narrower than any of my guesses

variantresult
const { Glob } = await import('bun'), --minifyBROKEN
same, without --minifyok
preceded by another await import(...)BROKEN
preceded by a plain awaitBROKEN
not destructured (const B = await import('bun'))BROKEN
separated from other statementsBROKEN

So it is neither the comma list, nor destructuring, nor an adjacent await. It is simply await import('<a Bun builtin>') + --minify. Nothing else is required.

Scope

This is not an stx defect — any Bun project that dynamically imports a Bun builtin and ships a minified bundle emits an undefined identifier and dies at runtime on that line. stx just happens to do it in the two CLI actions that #1889 and #1843 shipped.

Reproduced on 1.4.0-canary.1+9b678b407 and 1.4.0-canary.1+5b98630ac. I have not tested a stable release — but the published @stacksjs/stx@0.2.170 tarball carries the identical defect, so whichever Bun the release CI used has it too.

What stx should still do

Even once Bun fixes this, stx shouldn't wait for a toolchain release to make typecheck and codemod runnable. Bun is a runtime global, so the dynamic import buys nothing:

const { Glob } = globalThis.Bun

That sidesteps the rewrite entirely, is a two-line change across bin/cli.ts:1795 and :1852, and is correct independent of the bundler bug.

The prevention note stands unchanged, and is arguably the more valuable half: a smoke test that executes the built dist/cli.js for each registered command would have caught this the day it shipped. The test suite only ever evaluates src, where the construct is perfectly valid.

14 days ago

Fixed in 7b7406c146.

Both call sites now use globalThis.Bun.Glob. Bun is a global; the dynamic import bought nothing, and it was the only thing standing between the two commands and working.

Your diagnosis was exact, including that the two builds' differing minified identifiers proved it was the artifact rather than a local quirk. I reproduced it in my own dist (2 occurrences) before touching anything.

Why nothing caught it, since that is the part worth fixing: every test of these commands imports from src, where await import('bun') is perfectly valid — the defect is created by the bundler, so it exists only in dist. And it is not a syntax error: awaitPromise parses cleanly and fails at runtime, so parsing the artifact would not have found it either. The only thing that would have caught it is running the built file, which nothing did.

So the guard runs it — three checks that all fail when the construct returns: the fused identifier by name, both commands executed end to end, and the construct itself in the source with comments stripped first (the fix's own note names what it replaced, and matching that would fail on the explanation rather than on a reoccurrence). They skip rather than fail when dist is absent, since a fresh clone has none.

Sabotage-verified: reintroducing await import('bun') fails 3 of them.

Sign in to comment on this issue.