Skip to content

9. phaze-check

1. phaze-tsplugin ← editor (TS Language Service)
2. phaze-compile ← build-time AST rewriting
3. phaze-vite ← island HMR + chunking helpers
4. phaze-astro ← Astro integration (island model)
5. phaze-cloudflare ← native Cloudflare Workers adapter (whole-page)
─── Editor stack ────────────────────────────────────────────────────
6. phaze-language-tools ← Volar LSP backend (.phaze → virtual .tsx)
7. phaze-vscode ← VSCode extension (grammar + LSP client)
8. phaze-glow ← VSCode theme + halo runtime
─── Check ───────────────────────────────────────────────────────────
9. phaze-check ← headless tsc wrapper for CI ← you are here

@madenowhere/phaze-check is the CI-side half of the editor stack. Where phaze-vscode drives phaze-language-tools as an LSP for interactive hover types and diagnostics, phaze-check drives the same LanguagePlugin against the real tsc binary for headless type-checking — exit non-zero on any error, suitable for a GitHub Actions step or a pre-commit hook. Same pattern as vue-tsc, svelte-check, and astro check — the canonical “framework-aware tsc” pattern across the ecosystem.

tsc doesn’t know .phaze files exist. The extension isn’t in supportedTSExtensions, so:

  • tsc --noEmit silently skips every .phaze file — CI never catches type errors in pages written as .phaze.
  • Adding .phaze to tsconfig.json’s include array produces a File '…' has an unsupported file extension error.
  • Adding allowJs: true doesn’t help — .phaze isn’t .js either.

phaze-check is the bridge: it teaches tsc about .phaze and runs the same checker over the synthesised .tsx so type errors are caught exactly as they would be in handwritten .tsx.

Drop-in replacement for tsc --noEmit in CI:

Terminal window
pnpm add -D @madenowhere/phaze-check
package.json
{
"scripts": {
"check": "phaze-check --noEmit"
}
}

All tsc flags pass through — the bin runs the real TypeScript compiler in-process, just with .phaze registered:

Terminal window
pnpm exec phaze-check --noEmit
pnpm exec phaze-check --noEmit --watch
pnpm exec phaze-check --project tsconfig.app.json
pnpm exec phaze-check --noEmit --noUnusedLocals

Exit codes follow tsc:

  • 0 — no errors.
  • 1 — fatal (config error, missing file).
  • 2 — type errors found.

Diagnostics print at .phaze line / column positions, e.g.:

src/pages/about.phaze(12,14): error TS2322: Type 'string' is not assignable to type 'number'.

The mapping back from the synthetic .tsx happens inside Volar via the v3 sourcemap phaze-compile emits — same path the LSP uses, so error positions match what you’d see in VSCode.

  • No autofix. Same scope as tsc — report errors, exit non-zero. Fixing the code is on you.
  • No formatter. Use Prettier (or a future Phaze formatter — out of scope).
  • No phaze-compile diagnostics. Fence-shape / format-parse errors surface at build time via the Vite plugin’s code-frame overlay (Phase 2b). phaze-check is type-error-only; for full coverage, run pnpm build (catches format errors) and pnpm check (catches type errors) in CI.

The bin is ~30 lines:

src/run.ts
import { createRequire } from 'node:module'
import { getPhazeLanguagePluginForTsc } from '@madenowhere/phaze-language-tools'
const require = createRequire(import.meta.url)
export function runPhazeCheck() {
const tscPath = require.resolve('typescript/lib/tsc.js')
const { runTsc } = require('@volar/typescript/lib/quickstart/runTsc.js')
return runTsc(
tscPath,
['.phaze'],
() => [getPhazeLanguagePluginForTsc()],
)
}
bin/phaze-check.js
#!/usr/bin/env node
import { runPhazeCheck } from '../dist/run.js'
runPhazeCheck()

Three pieces matter:

  1. require.resolve('typescript/lib/tsc.js') finds the project’s tsc binary on disk — Node’s resolver walks workspace symlinks, hoisting, and pnpm’s isolated node_modules uniformly, so phaze-check uses the project’s TypeScript version, not its own bundled copy. Matches whatever tsc version your build does.
  2. @volar/typescript’s runTsc is the canonical helper Volar ships for this pattern. It hot-patches tsc.js at load time: intercepts fs.readFileSync for the tsc path, rewrites the source to inject the extra extension + a proxyCreateProgram call that consults our LanguagePlugin, then require()s the patched source in-process. The original tsc binary on disk is never modified — the rewrite happens in memory.
  3. getPhazeLanguagePluginForTsc() is the string-keyed flavor of the same LanguagePlugin phaze-language-tools exposes for the LSP (URI-keyed) — see Two factories. Same parser, same emit, same v3 sourcemap.

Visually:

phaze-check (bin/phaze-check.js)
src/run.ts
┌──────────────┴──────────────┐
▼ ▼
@volar/typescript @madenowhere/phaze-
.runTsc(tscPath, language-tools
['.phaze'], .getPhazeLanguage
() => [plugin]) PluginForTsc()
│ │
└──────────────┬──────────────┘
patched real `tsc.js`
(TypeScript compiler — your project's version)
exit 0 (clean) / 2 (type errors)

phaze-language-tools is the engine; phaze-check is one consumer of it. They split because:

  1. Different runtime hosts. #6 runs inside an LSP server process spawned by the editor — long-lived, continuous, against in-memory buffers. phaze-check runs inside a CI Node process — one-shot, against on-disk files, exits when done.
  2. Different deps. #6 pulls in @volar/language-server, @volar/language-service, vscode-uri, the stdio transport. phaze-check only needs @volar/typescript (for runTsc) plus #6’s LanguagePlugin factory. CI projects that don’t want VSCode-extension deps installed alongside their checker get a clean install.
  3. Different consumers. #6 is consumed by #7 (VSCode extension), future Cursor / JetBrains / Sublime integrations, and phaze-check. Bundling phaze-check into #6 would force every editor adapter to install the CLI bin path; bundling #6’s LSP machinery into phaze-check would force every CI project to install the language-server bits they never use.

The two share a LanguagePlugin factory — getPhazeLanguagePluginForTsc() for the headless string-keyed case, getPhazeLanguagePlugin() for the LSP’s URI-keyed case. Same body, same parser, same sourcemap; the key shape is the only difference, and both wrap an internal makePhazeLanguagePlugin<K> to share the implementation.

PathRole
package.jsonBin (phaze-check), deps on @madenowhere/phaze-language-tools + @volar/typescript, peer-dep on typescript.
bin/phaze-check.jsESM bin entry. Imports runPhazeCheck from dist/run.js, invokes it.
src/run.tsWraps @volar/typescript’s runTsc with getPhazeLanguagePluginForTsc().
src/index.tsRe-exports runPhazeCheck for programmatic embed (test harnesses, future phaze parent CLI).
README.mdInstall + CI recipes.
  • phaze-language-tools — the Volar LanguagePlugin this CLI consumes (and which the VSCode extension also drives, as an LSP).
  • phaze-vscode — interactive sibling. Same plugin, different transport.
  • phaze-compile — owns the .phaze parse / emit / sourcemap pipeline; phaze-check’s diagnostics route through the same v3 sourcemap.