diff --git a/AGENTS.md b/AGENTS.md index a31966e39..1affcdbb8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -731,6 +731,7 @@ webjs start [--port N] # prod server. No build st webjs test [--server] [--browser] [--watch] # unit + browser tests webjs check [--fix] # convention validator webjs types # generate .webjs/routes.d.ts (typed Route union + per-route params; #258). Opt-in; webjs dev emits it automatically +webjs typecheck [tsc args...] # type-check the app with the project's own tsc --noEmit (non-zero on errors; needs typescript installed) webjs create [--template api|saas] # scaffold a new app webjs db [...] # passthrough to prisma webjs ui init # @webjsdev/ui CLI diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index ed89b599a..70c7e8ca6 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -54,6 +54,7 @@ README.md npm-facing package readme. | `webjs test [--server\|--browser]` | `node --test` for server tests, `wtr` for browser tests | | `webjs check [--rules\|--fix]` | `checkConventions()` from `@webjsdev/server/check` | | `webjs types` | `generateRouteTypes()` from `@webjsdev/server`, writes `.webjs/routes.d.ts` (typed `Route` union + per-route params, #258). Also auto-emitted at `webjs dev` startup | +| `webjs typecheck [tsc args]` | Resolves the project's own `typescript/bin/tsc` (via `createRequire` from the app cwd) and spawns it with `--noEmit`, passing extra args through. Exits non-zero on a type error (a CI gate). A clear message + non-zero exit when typescript is not installed (#265). The framework runs the standard compiler, it does not embed one | | `webjs create [--template …]` | `scaffoldApp()` from `lib/create.js` | | `webjs db ` | Passthrough to `prisma` | | `webjs ui ` | Proxies to `@webjsdev/ui` (see "UI subcommand" below) | diff --git a/packages/cli/bin/webjs.js b/packages/cli/bin/webjs.js index b69501746..3e79fe0c4 100755 --- a/packages/cli/bin/webjs.js +++ b/packages/cli/bin/webjs.js @@ -43,6 +43,7 @@ const USAGE = `webjs commands: webjs test [--server|--browser] Run server + browser tests webjs check Run correctness checks on the app webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params) + webjs typecheck [tsc args...] Type-check the app with the project's tsc --noEmit (non-zero on errors) webjs create [--template full-stack|api|saas] [--no-install] Scaffold a new webjs app (only 3 templates exist. default: full-stack with Prisma+SQLite) Auto-runs the detected package manager's install in the new dir @@ -299,6 +300,32 @@ async function main() { ); break; } + case 'typecheck': { + // Type-check the app with the project's OWN tsc (it reads the app's + // tsconfig: strict + noEmit + erasableSyntaxOnly). The framework runs the + // standard compiler, it does not embed one. Extra args after `typecheck` + // pass through (e.g. `webjs typecheck --watch`). Exits non-zero on a type + // error, so it works as a CI gate and the scaffolded `typecheck` script. + const cwd = process.cwd(); + const { createRequire } = await import('node:module'); + let tscPath; + try { + const req = createRequire(join(cwd, 'package.json')); + tscPath = req.resolve('typescript/bin/tsc'); + } catch { + console.error( + 'webjs typecheck: TypeScript is not installed in this project.\n' + + 'Install it with `npm install -D typescript`, then re-run `webjs typecheck`.', + ); + process.exit(1); + } + const child = spawn(process.execPath, [tscPath, '--noEmit', ...rest], { + stdio: 'inherit', + cwd, + }); + child.on('exit', (code) => process.exit(code ?? 1)); + break; + } case 'create': { const name = rest[0]; if (!name || name.startsWith('-')) { diff --git a/packages/cli/lib/create.js b/packages/cli/lib/create.js index ccc69de9a..275ccbbf3 100644 --- a/packages/cli/lib/create.js +++ b/packages/cli/lib/create.js @@ -286,6 +286,7 @@ export async function scaffoldApp(name, cwd, opts = {}) { 'test:server': 'webjs test --server', 'test:browser': 'webjs test --browser', check: 'webjs check', + typecheck: 'webjs typecheck', 'db:migrate': 'prisma migrate dev', 'db:generate': 'prisma generate', 'db:studio': 'prisma studio', @@ -298,6 +299,10 @@ export async function scaffoldApp(name, cwd, opts = {}) { }, devDependencies: { prisma: '^6.0.0', + // The TypeScript compiler, for `npm run typecheck` (webjs typecheck runs + // tsc --noEmit). Not needed at runtime (Node strips types in place), only + // to type-check the app. + typescript: '^5.6.0', '@web/test-runner': '^0.20.0', '@web/test-runner-playwright': '^0.11.0', 'playwright': '^1.59.0', diff --git a/packages/cli/templates/CONVENTIONS.md b/packages/cli/templates/CONVENTIONS.md index 4536945f1..53703cfa8 100644 --- a/packages/cli/templates/CONVENTIONS.md +++ b/packages/cli/templates/CONVENTIONS.md @@ -86,6 +86,12 @@ even if the user doesn't explicitly ask.** 4. **Convention check.** Run `webjs check` after changes and fix any violations before reporting the task as done. +5. **Type check.** Run `npm run typecheck` (which runs `webjs typecheck`, + a `tsc --noEmit` over the app) and fix any type errors. `webjs check` is + correctness-only and does NOT type-check, so this is the separate + is-my-TypeScript-valid gate. It exits non-zero on a type error, so add it + to CI once the app type-checks cleanly. + ### Definition of done (MUST be addressed BEFORE opening the PR) This is the per-PR contract. Before running `gh pr create`, walk through diff --git a/test/cli/typecheck.test.mjs b/test/cli/typecheck.test.mjs new file mode 100644 index 000000000..798b4cb7c --- /dev/null +++ b/test/cli/typecheck.test.mjs @@ -0,0 +1,72 @@ +/** + * Tests for `webjs typecheck` (#265): a thin wrapper that runs the project's + * own `tsc --noEmit`, exits non-zero on type errors, and degrades gracefully + * with a clear message when TypeScript is not installed. + * + * The success / type-error fixtures live UNDER the repo so node resolves the + * repo's `typescript` (the CLI resolves tsc relative to the app cwd). The + * graceful-degradation fixture lives in the OS tmpdir, OUTSIDE the repo's + * node_modules tree, so `typescript` is genuinely unresolvable. + */ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO = resolve(__dirname, '..', '..'); +const CLI = resolve(REPO, 'packages', 'cli', 'bin', 'webjs.js'); + +const TSCONFIG = JSON.stringify({ + compilerOptions: { + noEmit: true, + strict: true, + erasableSyntaxOnly: true, + module: 'nodenext', + moduleResolution: 'nodenext', + skipLibCheck: true, + }, + include: ['*.ts'], +}); + +const cleanup = []; +after(() => { for (const d of cleanup) rmSync(d, { recursive: true, force: true }); }); + +/** Make a fixture app dir with a tsconfig and one source file. */ +function makeFixture(baseDir, source) { + const dir = mkdtempSync(join(baseDir, 'tc-')); + cleanup.push(dir); + writeFileSync(join(dir, 'package.json'), '{}'); + writeFileSync(join(dir, 'tsconfig.json'), TSCONFIG); + writeFileSync(join(dir, 'app.ts'), source); + return dir; +} + +function typecheck(cwd) { + return spawnSync(process.execPath, [CLI, 'typecheck'], { cwd, encoding: 'utf8' }); +} + +test('webjs typecheck exits 0 on a clean TypeScript app', () => { + const dir = makeFixture(REPO, 'export const n: number = 42;\n'); + const r = typecheck(dir); + assert.equal(r.status, 0, `expected exit 0, got ${r.status}\n${r.stdout}\n${r.stderr}`); +}); + +test('webjs typecheck exits non-zero and reports the error on a type error', () => { + const dir = makeFixture(REPO, 'const n: number = "not a number";\n'); + const r = typecheck(dir); + assert.notEqual(r.status, 0, 'a type error must produce a non-zero exit'); + assert.match(r.stdout + r.stderr, /TS2322|not assignable/, 'reports the tsc error'); +}); + +test('webjs typecheck degrades gracefully when TypeScript is not installed', () => { + // OS tmpdir is outside the repo node_modules tree, so typescript is unresolvable. + const dir = makeFixture(tmpdir(), 'export const n = 1;\n'); + const r = typecheck(dir); + assert.notEqual(r.status, 0, 'exits non-zero when typescript is missing'); + assert.match(r.stderr, /TypeScript is not installed/, 'prints a clear message'); + assert.match(r.stderr, /npm install -D typescript/, 'tells the user how to fix it'); +});