ReviewOS

stacks/stx

fix(stx-native): the CLI has never been executed — a nested backtick in its code generator is a syntax error

#1985
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Summary

packages/stx-native/src/cli/index.ts does not parse. Any invocation of the CLI — compile, build, run, dev, init — fails before it does anything, which means the package's entry point has never once been executed.

Evidence

$ bun src/cli/index.ts compile Screen.stx --platform ios
695 |       id: `init_${Date.now()}`,
                 ^
error: Expected ";" but found "init_$"

src/cli/index.ts:695 sits inside a template literal that generates JavaScript, and emits another backtick without escaping it. The outer literal ends early and the rest is parsed as code.

    // Render initial UI
    bridge.postMessage(JSON.stringify({
      id: `init_${Date.now()}`,     // ← this backtick closes the generator's own literal

Every other module in the package imports cleanly — compiler/parser.ts, compiler/ir.ts, compiler/headwind-to-style.ts, bridge/protocol.ts, components/primitives.ts, runtime/index.ts, hot-reload/client.ts, index.ts. The CLI is the only one that does not load.

This is the failure mode CLAUDE.md note 41 documents for the client runtimes ("the client runtimes are TEMPLATE LITERALS — no backticks, not even in comments"), in a file that generates code rather than one that ships it.

Ask

  1. Escape the nested backticks (\`) in the generated-code literals, or build that string with concatenation so a backtick in the output cannot terminate the generator.
  2. Add a smoke test that runs the CLI with --help and asserts exit 0. The bug survived because nothing ever invoked the binary.
9 days ago

Part of #1992 (stx-native: native views for wildloop).

9 days ago

Fixed in 2ec82de1d1. Both asks, plus two more defects that were sitting behind the parse error.

1. The escape

Escaped the backticks, and the ${ with them. Escaping only the backticks would have left the interpolation to the generator, baking one timestamp into the emitted bundle so every run correlated its first message under the same id. It has to be the moment the bundle initializes, which is what the generated id: `init_${Date.now()}` means.

2. The smoke test, and what it found

Writing it turned up the next two problems immediately, which is the point you were making about nothing ever invoking the binary.

compile <file> could not receive its file. run() read args[1] as a subcommand and began flag parsing at args[2]. That is right for run ios and build android, and it eats the only argument of every command that takes a positional:

$ bun src/cli/index.ts compile Screen.stx --platform ios
Please specify an input file: stx-native compile <file.stx>

A documented command, refusing an argument it was given. Positionals and flags are now parsed apart, starting from the argument after the command. That also removed the cast that hid it: positionals were pushed onto flags._, declared as a string through as unknown as string so it would fit a Record<string, string>, which is exactly what let flags._[0] compile when on the declared type it is a character of a string.

The module ran the CLI on import. It called cli.run(process.argv.slice(2)) at top level and then exported STXCLI, so importing it ran the CLI against the importer's argv. For a test runner that is an unknown command: help printed, then process.exit(1) took the runner down. The file could be executed or imported from, never both, and the export at the bottom says which was intended. Guarded with import.meta.main, the same guard benchmarks/src/regression.ts and bun-plugin/src/serve.ts use.

It works now

$ bun src/cli/index.ts --help
STX Native CLI
...
$ bun src/cli/index.ts compile Screen.stx
{ "version": "1.0.0", "root": { "type": "View", "style": { "padding": 16 },
  "children": [ { "type": "Text", "style": { "fontSize": 18 }, ... } ] } }

p-4 to padding: 16 and text-lg to fontSize: 18, so the Headwind conversion is reachable for the first time too.

The tests

8, and the first in this package. They spawn the entry point as a subprocess rather than importing it: the bug was a parse error, and a test that imports a module it expects to be broken proves less than one that runs it the way a person does. Two of them cover the generator's output, since that is what the unescaped backtick was in the middle of, asserting that the bundle parses as JavaScript and that the timestamp is still deferred rather than baked.

Verified both ways: without the fix the test file cannot load at all, failing with the original Expected ";" but found "init_$". With it, 8 pass. tsc --noEmit clean, lint clean.

Worth noting this does not touch #1986. Eight tests for a CLI is not coverage of 8,012 lines; it is a smoke test on the one file that could not run.

Sign in to comment on this issue.