ReviewOS

stacks/stx

feat: ship a CI-runnable type-checker for .stx (vue-tsc equivalent) — today tsc cannot see inside a template, so logic gets relocated out of it just to be checked

#1852
Closed glennmichael123 opened this 10 days ago ·
10 days ago

Summary

There is no way to type-check a .stx file outside an editor. tsc treats .stx as an unknown extension, the stx CLI has no typecheck/check command, and the only checking that exists is a VS Code tsserver plugin that concatenates all script blocks into one virtual file and then filters unknown-identifier errors away. The practical consequence is that a page's <script server> block, its <script client> block and every expression are unverified, and the only way to get type coverage on template logic is to move that logic into a .ts file on the tsconfig include path.

Impact (real app)

analyticshq is an SSR-first app whose dashboard page carries ~500 lines of SQL and data shaping in resources/views/dashboard.stx:220-758. We physically split that page across three files by the criterion "can tsc see it" rather than by cohesion.

resources/views/dashboard.stx:8-13 records the decision at the top of the server block:

The pure formatters and the channel classifier live in a real .ts module rather than in this block, because tsc cannot see inside a .stx file -- an annotation written here is erased by the transpiler and checked by nothing, while app/Support is on the tsconfig include path.

So 119 lines moved to app/Support/dashboard-format.ts purely for checkability (its header at :4-8 repeats the reason). tsconfig.json:35-43 confirms the split — include lists types/**/*.ts, resources/stores/**/*.ts, app/Models, app/Services, app/Support; resources/views is never included, because including it would do nothing.

For what could not be moved (request-scoped helpers, chart geometry consumed by the template) we hand-wrote 56 lines of ambient interfaces in types/dashboard.d.ts:16-56, with an honest disclaimer at :10-13:

tsc cannot see inside .stx, so these annotate the page for a human and an editor and are verified by nothing. They are still worth writing, but a wrong shape here is a real defect that no build step will catch.

Net: 119 lines relocated against cohesion, 56 lines of types that verify nothing, ~500 lines of query/shaping code with zero coverage, and no CI gate that would catch a renamed column or a changed row shape.

Evidence in stx

  • No CLI entry point. packages/stx/bin/cli.ts registers 28 commands (docs, dev, a11y, build, start, deploy, bundle, test, init, format, doctor, debug, status, watch, analyze, screenshot, interactive, story*, compile, …). There is no typecheck or check.
  • No published checker package. packages/ contains bun-plugin, router, components, vscode, stx, … and nothing resembling stx-tsc or a language server.
  • The type machinery that exists is unexported. packages/stx/src/typescript-templates.ts:601 (typeCheckExpression) and :686 (validateTemplateTypes, which walks expressions) are real functions, but packages/stx/src/index.ts never re-exports ./typescript-templates, so no consumer can call them.
  • The editor path is lossy in two specific ways. packages/vscode/src/typescript-stx-plugin.ts:19-26 builds the virtual TS file by appending every <script> body in the file, server and client together — two blocks legitimately declaring the same local name produce a spurious redeclaration, and a client block silently "sees" server bindings it cannot reach at runtime. Then :157-179 proxies getSemanticDiagnostics and drops every TS2304 (Cannot find name) whose message mentions one of a hardcoded list (props, state, derived, effect, onMount, loop, …), which also suppresses genuine typos in template expressions.
  • packages/stx/package.json:115 is bun tsc -p tsconfig.src.json --noEmit — that checks stx's own .ts sources, not templates.

Prior art

  • Vue: vue-tsc (Volar) — CLI, runs in CI, checks the SFC script AND template expressions against the component's props.
  • Svelte: svelte-check.
  • Angular: strictTemplates in tsconfig.json, enforced by ng build.
  • Astro: astro check.
  • React/TSX: no special tooling needed — it is TypeScript.

All of them provide a virtual-file mapping with source positions, so a template error reports at the right line in the .stx/.vue file.

Ask

  1. Ship stx typecheck [patterns...] as a CLI command that exits non-zero on error, so it can be a CI step and a pre-commit hook.
  2. Build the virtual TS file per script BLOCK, not per file: a <script server> and a <script client> in the same page are separate scopes and must not share a declaration space.
  3. Declare stx's runtime globals as ambient types for the client scope (derived from STX_RUNTIME_GLOBALS in packages/stx/src/runtime-globals.ts, which is already the single source of truth) instead of filtering TS2304 away in typescript-stx-plugin.ts:157-179. Same for the server scope's injected context (params, query, cookies, host, ip, setResponseStatus — see packages/bun-plugin/src/serve.ts:1281-1330).
  4. Type-check expressions and directive attribute expressions (:if, :for, @click) against the enclosing scope — packages/stx/src/typescript-templates.ts:686 already walks them; export it and give it a real checker instead of the hand-rolled member-access walk at :601-680.
  5. Emit a source map from the virtual file back to .stx offsets so errors point at the template line, and reuse the same mapping in the VS Code plugin so editor and CI agree.

Related: #1791, #1792

10 days ago

Taking this. bench feels this directly — logic gets relocated out of templates just so tsc can see it (documented as tech debt on our side). It's also an isolated new tool, so no collision risk with the in-flight runtime work. Flagging to avoid double-work.

10 days ago

Claiming this one — working on it now.

10 days ago

Asks 4 and 5 are done in cb4e91c. Asks 1–3 landed earlier in dbce42b.

Ask 4 — and directive expressions are checked against the enclosing scope.

The loop binding is typed from the iterable it came from, so the case this issue reports as uncovered now fails to compile:

dashboard.stx:9:14  error TS2551  Property 'total_vists' does not exist on type 'Row'.
                                  Did you mean 'total_visits'?  [template: row.total_vists]

That is @foreach(rows as row) with rows: Row[] declared in <script server>. Only a plain identifier is followed — a call or a member chain stays any, because guessing there invents errors.

Measured before shipping, against this repo's 173-file .stx corpus. The first run produced 400 diagnostics over 2072 expressions, all false positives. The rules that fixed them, in order of size: is a template comment (305); :show="photos()[]" splices a server interpolation into a client expression (94); filter chains (name | upper, but not ||); and @click="open = !open", which assigns to what tsc sees as a const and is documented syntax, so it is not modelled rather than reported.

Final: 2072 expressions, 1 diagnostic, and it is a real bug — examples/components/buddy/terminal.stx has an unterminated string literal inside , so it renders empty. new Function rejects it too.

Ask 5 — the editor and CI now share one extractor.

The plugin's three problems are all gone. It appended every script body into one buffer and dropped the markup, so every diagnostic landed on the wrong line, further off the further down the file. Blocks collided, because a <script server> and a <script client> that both declare items are separate scopes at runtime. And it dropped every TS2304 mentioning a hardcoded list of globals, which swallowed Cannot find name 'stcate' along with state. Lines are now preserved by construction, globals are declared rather than filtered, and the only suppression left is computed per file: a name declared on both sides of the server/client boundary.

Two pre-existing bugs in the shipped checker turned up while doing this:

  • A block buffer was not a module, so every top-level declaration collided with lib.dom. const name and const status in a <script server> block both reported "Cannot redeclare block-scoped variable" — ordinary names for page data, failing the CI gate for nothing.
  • Diagnostics were attributed by endsWith on a sanitised basename. Sanitising is not injective (a/b.stx and a_b.stx collide, one silently overwriting the other), and sub/views/a.stx ends with views/a.stx, so errors were reported against the wrong file.

Two things deliberately not done, worth recording:

  1. typescript-templates.ts stays unexported. The ask was to export typeCheckExpression and give it a real checker. It got the real checker; exporting the hand-rolled member-access walk as public API would leave two template checkers to drift apart, which is a failure mode this repo keeps paying for. Documented in place instead.

  2. A client block can still see a server binding in the editor. tsserver maps one file to one snapshot, so blocks cannot be given separate modules the way the CLI does. That is a missing error rather than an invented one; closing it needs a real language server (what Volar is to Vue), which is a larger change than this.

Known depth limit: template expressions get real types where the server declares them, but the runtime globals are typed any (runtime-globals.ts), so a signal's value type is unknown and NaN is unchecked. Typing that surface is a separate, larger change — and it is also what would let the checker reason about the auto-unwrap rule (:if="flag" reads the value, @click="flag.set(true)" reads the signal). Filing separately.

Sign in to comment on this issue.