diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index e99dc177..2456f924 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -4,7 +4,7 @@ import { buildRouter, createRouteManifest, nameRoutes } from "@wrnexus/router"; // Keep the CLI checker sourced from the package contract so typecheck fixes are // included in each published CLI bundle. import { checkWrnFile, type WrnTypeDiagnostic } from "@wrnexus/typecheck"; -import { parse } from "@wrnexus/syntax"; +import { parse, type PageAst } from "@wrnexus/syntax"; import { generate, generateTargets } from "@wrnexus/compiler"; import { regenerateRoutes } from "./routes.ts"; import { loadAppConfig } from "@wrnexus/styles"; @@ -99,6 +99,42 @@ function writePluginArtifacts(root: string, contributions?: PluginContributions) ); } +/** + * Type assertions for sectioned api blocks. + * + * Enforcement lives here rather than in the compiler because this file is under + * `app/` and is therefore compiled by the project's own tsc, while generated + * build artifacts are not type-checked at all. + */ +function apiBlockAssertions(pages: { path: string; ast: PageAst }[], apiContracts: string): string { + const lines: string[] = []; + + for (const page of pages) { + for (const block of page.ast.dataApis) { + if (!block.sections) continue; + + if (!apiContracts.includes(JSON.stringify(block.path))) { + console.warn( + `[wrnexus] api block "${block.name}" targets ${block.path}, which has no defineEndpoint contract — its declared types are not checked.`, + ); + } + + const fields = [...block.sections.parameters, ...block.sections.body]; + const shape = fields.length + ? `{ ${fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }` + : "Record"; + + lines.push( + ` type __wrn_api_check_${block.name} = __wrn_expect_true>>;`, + ); + } + } + + return lines.join("\n"); +} + export function generateApplicationTypes( appRoot: string, pluginContributions?: PluginContributions, @@ -145,6 +181,10 @@ export function generateApplicationTypes( return ` ${JSON.stringify(component.name)}: { props: ${props ? `{ ${props} }` : "Record"}; outputs: ${outputs ? `{ ${outputs} }` : "Record"} };`; }) .join("\n"); + const pageAsts = files(app, (path) => extname(path) === ".wrn").map((file) => ({ + path: file, + ast: parse(readFileSync(file, "utf8")), + })); const typeDir = join(app, "types"); const apiContracts = router.api .map((route) => { @@ -218,6 +258,11 @@ declare namespace WRNexusGenerated { ${generatedContractMap("RealtimeMessages", realtimeContracts)} ${generatedContractMap("QueuePayloads", queueContracts)} type ApplicationConfig = ${configFile ? `(typeof import(${typeImport(typeDir, configFile)}))["default"]` : "Record"}; + type AssertAssignable = [Actual] extends [Expected] ? true : false; + type __wrn_expect_true = T; + type ApiInput

= ApiContracts[P][M]["input"]; + type ApiOutput

= ApiContracts[P][M]["output"]; +${apiBlockAssertions(pageAsts, apiContracts)} } `; mkdirSync(typeDir, { recursive: true }); diff --git a/packages/cli/test/api-block-types.test.ts b/packages/cli/test/api-block-types.test.ts new file mode 100644 index 00000000..5bb2d712 --- /dev/null +++ b/packages/cli/test/api-block-types.test.ts @@ -0,0 +1,73 @@ +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 }); +}); + +/** Minimal app with one typed endpoint and one page that calls it. */ +function fixture(block: string): string { + const root = mkdtempSync(join(tmpdir(), "wrnexus-api-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 client {\n${block}\n }\n\n view {

x
}\n}\n`, + ); + return root; +} + +const BLOCK = ` api searchUsers POST /api/users { + request { + body { + name?: string + age?: number + } + } + + response { + return data.users + } + }`; + +test("emits the ApiInput, ApiOutput and AssertAssignable helpers", () => { + const root = fixture(BLOCK); + generateApplicationTypes(root); + const generated = readFileSync(join(root, "app/types/wrnexus.generated.d.ts"), "utf8"); + + expect(generated).toContain("type AssertAssignable<"); + expect(generated).toContain('type ApiInput

= ApiContracts[P][M]["input"]'); + expect(generated).toContain( + 'type ApiOutput

= ApiContracts[P][M]["output"]', + ); +}); + +test("emits one assertion per sectioned block, naming its route and method", () => { + const root = fixture(BLOCK); + generateApplicationTypes(root); + const generated = readFileSync(join(root, "app/types/wrnexus.generated.d.ts"), "utf8"); + + expect(generated).toContain("__wrn_api_check_searchUsers"); + expect(generated).toContain('ApiInput<"/api/users", "POST">'); + expect(generated).toContain("name?: string"); + expect(generated).toContain("age?: number"); +}); + +test("a legacy bare-body block produces no assertion", () => { + const root = fixture(` api legacyUsers GET /api/users { + return users.length + }`); + generateApplicationTypes(root); + const generated = readFileSync(join(root, "app/types/wrnexus.generated.d.ts"), "utf8"); + + expect(generated).not.toContain("__wrn_api_check_legacyUsers"); +});