From de99a2c2e0ce4114591ce3974651c750cd427c42 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Thu, 20 Aug 2026 07:44:58 +0530 Subject: [PATCH] feat(cli): assert types for every api block with declared fields Co-Authored-By: Claude Opus 5 --- packages/cli/src/types.ts | 25 +++++------ packages/cli/test/apis-block-types.test.ts | 48 ++++++++++++++++++++++ 2 files changed, 61 insertions(+), 12 deletions(-) create mode 100644 packages/cli/test/apis-block-types.test.ts diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index c56b10a0..907fbe7a 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -138,19 +138,20 @@ function apiBlockAssertions( for (const block of page.ast.dataApis) { if (!block.sections) continue; - // ssr-mode sectioned blocks can never declare a `request` (they are - // render-time only), so they always fall back to the empty-shape - // `Record` below. `keyof Record` is - // `string`, which makes the key-exactness arm of AssertAssignable - // evaluate to `false` unconditionally and raises TS2344 on every such - // block regardless of whether the block author did anything wrong. - // We choose to skip emission for both (a) any non-client-mode block, - // since it structurally can never have a request to check, and (b) any - // block -- client included -- that has zero declared request fields, - // since there is nothing to assert type-safety about. This is more - // honest about intent than emitting a vacuous/always-failing check. + // A block with zero declared request fields would fall back to the + // empty-shape `Record` below. `keyof Record` is `string`, which makes the key-exactness arm of + // AssertAssignable evaluate to `false` unconditionally and raises + // TS2344 on every such block regardless of whether the block author + // did anything wrong. We skip emission for any block with zero + // declared request fields, since there is nothing to assert + // type-safety about. This applies regardless of mode: `ssr` blocks can + // never declare a `request` and so are always caught by this same + // check; mode-less (`any`) and `client` blocks can declare fields and + // get an assertion whenever they do. This is more honest about intent + // than emitting a vacuous/always-failing check. const fields = [...block.sections.parameters, ...block.sections.body]; - if (block.mode !== "client" || fields.length === 0) continue; + if (fields.length === 0) continue; if (!apiContracts.includes(JSON.stringify(block.path))) { console.warn( diff --git a/packages/cli/test/apis-block-types.test.ts b/packages/cli/test/apis-block-types.test.ts new file mode 100644 index 00000000..48fff506 --- /dev/null +++ b/packages/cli/test/apis-block-types.test.ts @@ -0,0 +1,48 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { generateApplicationTypes } from "../src/types.ts"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function fixture(apisBlock: string): string { + const root = mkdtempSync(join(tmpdir(), "wrnexus-apis-types-")); + roots.push(root); + mkdirSync(join(root, "app/pages"), { recursive: true }); + mkdirSync(join(root, "app/api"), { recursive: true }); + writeFileSync( + join(root, "app/api/users.ts"), + `export const POST = async () => Response.json({ users: [] });\n`, + ); + writeFileSync( + join(root, "app/pages/search.wrn"), + `page Search {\n apis {\n${apisBlock}\n }\n\n view {
x
}\n}\n`, + ); + return root; +} + +test("a mode-less block with declared fields gets an assertion", () => { + const root = fixture(` searchUsers POST /api/users { + request { body { name?: string } } + response { return data.users } + }`); + generateApplicationTypes(root); + const generated = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8"); + + expect(generated).toContain("searchUsers"); + expect(generated).toContain('ApiInput<"/api/users", "POST">'); +}); + +test("a block with no declared fields gets no assertion", () => { + const root = fixture(` listAll GET /api/users { + response { return data.users } + }`); + generateApplicationTypes(root); + const generated = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8"); + + expect(generated).not.toContain("listAll"); +});