From e944fd449647a5f7017cc4d1d4a1e11795e32c1e Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Thu, 20 Aug 2026 18:51:19 +0530 Subject: [PATCH] feat(editor): complete tooling support for the apis block Task 6 Step 2 was already performed and confirmed the assertion error from the apis-block type checks lands on the apis { } block itself (WRN-TYPE-2344), not on the offending entry, so it already surfaces usefully and needed no relocation mapping. That observation surfaced a real pre-existing bug: the virtual TypeScript document built for type checking declared `server` from ast.dataApis-adjacent runtime functions but never declared `api`, so every api.(...) call raised a false 'Cannot find name apis' plus a knock-on implicit-any on its result. Fixes it by declaring `api` from ast.dataApis, mirroring the existing `server` declaration: each entry gets an input parameter shaped from its request parameters/body fields (optional when the entry declares none) and a Promise return. The binding is only emitted when the page has an apis { } block, so pages without one keep the legitimate 'Cannot find name api' diagnostic and 'state api' stays legal. Co-Authored-By: Claude Opus 5 --- editors/vscode/src/language-server.cjs | 9 +- packages/typecheck/src/index.ts | 17 ++ .../typecheck/test/api-block-binding.test.ts | 146 ++++++++++++++++++ 3 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 packages/typecheck/test/api-block-binding.test.ts diff --git a/editors/vscode/src/language-server.cjs b/editors/vscode/src/language-server.cjs index 9cb78f44..b623630a 100644 --- a/editors/vscode/src/language-server.cjs +++ b/editors/vscode/src/language-server.cjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// WRN editor language server source hash: a8bac4fc444116cc73f898d51989b058370ba9cfd8413b62974a8ce2d841ab6f +// WRN editor language server source hash: eca7e58f47277a649cf9f7b2fcf23d2db52c18ff64e3f3ffb0b442d9d5f2024d // WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72 // @bun @bun-cjs (function(exports, require, module, __filename, __dirname) {var __create = Object.create; @@ -172995,12 +172995,19 @@ function virtualTypeScriptModule(source, filePath = "component.wrn", appRoot = f const outputType = ast.outputs.map((output) => `${output.name}: (${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}) => void`).join("; "); const serverFunctions = ast.runtimeFunctions.filter((fn) => fn.runtime === "server"); const serverType = serverFunctions.map((fn) => `${fn.name}: (${fn.parameters.map((param) => `${param.name}${param.optional ? "?" : ""}: ${param.valueType ?? "unknown"}`).join(", ")}) => ${fn.returnType ?? (fn.async ? "Promise" : "unknown")}`).join("; "); + const apiType = ast.dataApis.map((block) => { + const fields = block.sections ? [...block.sections.parameters, ...block.sections.body] : []; + const inputType = fields.length ? `{ ${fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }` : "Record"; + return `${block.name}: (input${fields.length ? "" : "?"}: ${inputType}) => Promise`; + }).join("; "); append(`declare const output: { [name: string]: (...args: any[]) => void; ${outputType} };`); for (const output of ast.outputs) append(`declare const ${output.name}: (${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}) => void;`); for (const match of source.matchAll(/@event\s+([A-Za-z_$][\w$]*)\s*=\s*function/g)) append(`declare const ${match[1]}: ((...args: unknown[]) => void) | undefined;`); append(`declare const server: { ${serverType} };`); + if (ast.dataApis.length) + append(`declare const api: { ${apiType} };`); append(`declare const props: Readonly<${ast.name}Props>;`); append("declare const refs: Record;"); append(ast.kind === "global-store" || ast.kind === "page-store" ? storeContract(ast) : componentContract(ast)); diff --git a/packages/typecheck/src/index.ts b/packages/typecheck/src/index.ts index 266524da..ede56998 100644 --- a/packages/typecheck/src/index.ts +++ b/packages/typecheck/src/index.ts @@ -280,6 +280,22 @@ export function virtualTypeScriptModule( `${fn.name}: (${fn.parameters.map((param) => `${param.name}${param.optional ? "?" : ""}: ${param.valueType ?? "unknown"}`).join(", ")}) => ${fn.returnType ?? (fn.async ? "Promise" : "unknown")}`, ) .join("; "); + // `apis { }` introduces an `api.(...)` binding, callable from server, + // client, and shared functions alike (see codegen.ts/client-codegen.ts). It + // must be typed here or every `api.()` call raises a false "Cannot + // find name 'api'" plus a knock-on implicit-any on its result. A page with + // no `apis { }` block must NOT get this binding — that would mask the + // legitimate "Cannot find name 'api'" error on a stray reference, and would + // shadow the otherwise-legal `state api`. + const apiType = ast.dataApis + .map((block) => { + const fields = block.sections ? [...block.sections.parameters, ...block.sections.body] : []; + const inputType = fields.length + ? `{ ${fields.map((f) => `${f.name}${f.optional ? "?" : ""}: ${f.type}`).join("; ")} }` + : "Record"; + return `${block.name}: (input${fields.length ? "" : "?"}: ${inputType}) => Promise`; + }) + .join("; "); append(`declare const output: { [name: string]: (...args: any[]) => void; ${outputType} };`); for (const output of ast.outputs) append( @@ -288,6 +304,7 @@ export function virtualTypeScriptModule( for (const match of source.matchAll(/@event\s+([A-Za-z_$][\w$]*)\s*=\s*function/g)) append(`declare const ${match[1]}: ((...args: unknown[]) => void) | undefined;`); append(`declare const server: { ${serverType} };`); + if (ast.dataApis.length) append(`declare const api: { ${apiType} };`); append(`declare const props: Readonly<${ast.name}Props>;`); append("declare const refs: Record;"); append( diff --git a/packages/typecheck/test/api-block-binding.test.ts b/packages/typecheck/test/api-block-binding.test.ts new file mode 100644 index 00000000..11e3e7c0 --- /dev/null +++ b/packages/typecheck/test/api-block-binding.test.ts @@ -0,0 +1,146 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { checkWrnSource } from "../src/index.ts"; + +function app(): string { + const root = mkdtempSync(join(tmpdir(), "wrn-typecheck-api-")); + mkdirSync(join(root, "app", "types"), { recursive: true }); + return root; +} + +test("api.(...) in a client function produces no diagnostic", () => { + const root = app(); + const source = `page Demo { + apis { + searchDirectory POST /api/directory { + request { + body { + name?: string + } + } + + response { + return data.data.users + } + } + } + + functions { + client async function search(): Promise { + await api.searchDirectory({ name: "a" }) + } + } + + view {
} +}`; + const diagnostics = checkWrnSource(source, { + appRoot: root, + filePath: join(root, "app", "pages", "Demo.wrn"), + }); + expect(diagnostics.some((d) => /Cannot find name 'api'/.test(d.message))).toBe(false); + expect(diagnostics).toHaveLength(0); +}); + +test("the implicit-any knock-on from an undeclared api binding is gone", () => { + const root = app(); + const source = `page Demo { + apis { + searchDirectory POST /api/directory { + request { + body { + name?: string + } + } + + response { + return data.data.users + } + } + } + + functions { + client async function search(): Promise { + const result = await api.searchDirectory({ name: "a" }) + } + } + + view {
} +}`; + const diagnostics = checkWrnSource(source, { + appRoot: root, + filePath: join(root, "app", "pages", "Demo.wrn"), + }); + expect(diagnostics.some((d) => d.code === "WRN-TYPE-7006")).toBe(false); +}); + +test("a page without an apis {} block still errors on a bare api. reference", () => { + const root = app(); + const source = `page Demo { + functions { + client async function search(): Promise { + await api.searchDirectory({ name: "a" }) + } + } + + view {
} +}`; + const diagnostics = checkWrnSource(source, { + appRoot: root, + filePath: join(root, "app", "pages", "Demo.wrn"), + }); + expect(diagnostics.some((d) => /Cannot find name 'api'/.test(d.message))).toBe(true); +}); + +test("state api on a page without an apis {} block stays legal", () => { + const root = app(); + const source = `page Demo { + state api = "idle" + + functions { + client function reset(): void { + api = "idle" + } + } + + view {
{api}
} +}`; + const diagnostics = checkWrnSource(source, { + appRoot: root, + filePath: join(root, "app", "pages", "Demo.wrn"), + }); + expect(diagnostics.some((d) => /Cannot find name 'api'/.test(d.message))).toBe(false); +}); + +test("calling an undeclared block name on api errors", () => { + const root = app(); + const source = `page Demo { + apis { + searchDirectory POST /api/directory { + request { + body { + name?: string + } + } + + response { + return data.data.users + } + } + } + + functions { + client async function search(): Promise { + await api.nope({ name: "a" }) + } + } + + view {
} +}`; + const diagnostics = checkWrnSource(source, { + appRoot: root, + filePath: join(root, "app", "pages", "Demo.wrn"), + }); + expect(diagnostics.some((d) => /Property 'nope' does not exist/.test(d.message))).toBe(true); +});