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.<name>(...) 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<any> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/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
|
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
|
||||||
// @bun @bun-cjs
|
// @bun @bun-cjs
|
||||||
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
|
(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 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 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>" : "unknown")}`).join("; ");
|
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>" : "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<string, never>";
|
||||||
|
return `${block.name}: (input${fields.length ? "" : "?"}: ${inputType}) => Promise<any>`;
|
||||||
|
}).join("; ");
|
||||||
append(`declare const output: { [name: string]: (...args: any[]) => void; ${outputType} };`);
|
append(`declare const output: { [name: string]: (...args: any[]) => void; ${outputType} };`);
|
||||||
for (const output of ast.outputs)
|
for (const output of ast.outputs)
|
||||||
append(`declare const ${output.name}: (${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}) => void;`);
|
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))
|
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 ${match[1]}: ((...args: unknown[]) => void) | undefined;`);
|
||||||
append(`declare const server: { ${serverType} };`);
|
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 props: Readonly<${ast.name}Props>;`);
|
||||||
append("declare const refs: Record<string, Element | null>;");
|
append("declare const refs: Record<string, Element | null>;");
|
||||||
append(ast.kind === "global-store" || ast.kind === "page-store" ? storeContract(ast) : componentContract(ast));
|
append(ast.kind === "global-store" || ast.kind === "page-store" ? storeContract(ast) : componentContract(ast));
|
||||||
|
|||||||
@@ -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>" : "unknown")}`,
|
`${fn.name}: (${fn.parameters.map((param) => `${param.name}${param.optional ? "?" : ""}: ${param.valueType ?? "unknown"}`).join(", ")}) => ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")}`,
|
||||||
)
|
)
|
||||||
.join("; ");
|
.join("; ");
|
||||||
|
// `apis { }` introduces an `api.<name>(...)` binding, callable from server,
|
||||||
|
// client, and shared functions alike (see codegen.ts/client-codegen.ts). It
|
||||||
|
// must be typed here or every `api.<name>()` 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<string, never>";
|
||||||
|
return `${block.name}: (input${fields.length ? "" : "?"}: ${inputType}) => Promise<any>`;
|
||||||
|
})
|
||||||
|
.join("; ");
|
||||||
append(`declare const output: { [name: string]: (...args: any[]) => void; ${outputType} };`);
|
append(`declare const output: { [name: string]: (...args: any[]) => void; ${outputType} };`);
|
||||||
for (const output of ast.outputs)
|
for (const output of ast.outputs)
|
||||||
append(
|
append(
|
||||||
@@ -288,6 +304,7 @@ export function virtualTypeScriptModule(
|
|||||||
for (const match of source.matchAll(/@event\s+([A-Za-z_$][\w$]*)\s*=\s*function/g))
|
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 ${match[1]}: ((...args: unknown[]) => void) | undefined;`);
|
||||||
append(`declare const server: { ${serverType} };`);
|
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 props: Readonly<${ast.name}Props>;`);
|
||||||
append("declare const refs: Record<string, Element | null>;");
|
append("declare const refs: Record<string, Element | null>;");
|
||||||
append(
|
append(
|
||||||
|
|||||||
@@ -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.<name>(...) 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<void> {
|
||||||
|
await api.searchDirectory({ name: "a" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <div></div> }
|
||||||
|
}`;
|
||||||
|
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<void> {
|
||||||
|
const result = await api.searchDirectory({ name: "a" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <div></div> }
|
||||||
|
}`;
|
||||||
|
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<void> {
|
||||||
|
await api.searchDirectory({ name: "a" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <div></div> }
|
||||||
|
}`;
|
||||||
|
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 { <div>{api}</div> }
|
||||||
|
}`;
|
||||||
|
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<void> {
|
||||||
|
await api.nope({ name: "a" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view { <div></div> }
|
||||||
|
}`;
|
||||||
|
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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user