feat: close application architecture gaps
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/typecheck",
|
||||
"version": "0.8.11",
|
||||
"version": "0.8.13",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -307,6 +307,9 @@ export function virtualTypeScriptModule(
|
||||
if (ast.dataApis.length) append(`declare const api: { ${apiType} };`);
|
||||
append(`declare const props: Readonly<${ast.name}Props>;`);
|
||||
append("declare const refs: Record<string, Element | null>;");
|
||||
append(
|
||||
"declare function useFetch(path: string, method?: string | { method?: string; query?: unknown; params?: unknown; body?: unknown; data?: unknown }, input?: unknown): Promise<any>;",
|
||||
);
|
||||
append(
|
||||
ast.kind === "global-store" || ast.kind === "page-store"
|
||||
? storeContract(ast)
|
||||
@@ -332,6 +335,59 @@ export function virtualTypeScriptModule(
|
||||
for (const fn of functions) append(functionDeclaration(fn), fn.source);
|
||||
append("}");
|
||||
}
|
||||
const viewFunctions = new Map<string, RuntimeFunctionDecl["runtime"]>();
|
||||
for (const fn of ast.runtimeFunctions) {
|
||||
if (fn.runtime !== "server") viewFunctions.set(fn.name, fn.runtime);
|
||||
}
|
||||
for (const [name, runtime] of viewFunctions) {
|
||||
append(`declare const ${name}: typeof ${runtimeNamespace(runtime)}.${name};`);
|
||||
}
|
||||
|
||||
// View expressions execute in the client scope, but historically were not
|
||||
// represented in the virtual TypeScript module. Validate them here so a
|
||||
// misspelled state/function name cannot silently become `undefined` at
|
||||
// runtime. Each/handler locals mirror the browser evaluator's bindings.
|
||||
let bindingIndex = 0;
|
||||
const appendViewBindings = (nodes: ViewNode[], locals: Set<string>): void => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === "element") {
|
||||
for (const attr of node.attrs) {
|
||||
const expressions = attr.event
|
||||
? [attr.value]
|
||||
: [...attr.value.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1]!.trim());
|
||||
for (const expression of expressions) {
|
||||
if (!expression.trim()) continue;
|
||||
const declarations = [
|
||||
...[...locals].map((name) => `declare const ${name}: any;`),
|
||||
...(attr.event ? ["declare const payload: any;", "declare const event: Event;"] : []),
|
||||
].join(" ");
|
||||
append(
|
||||
`namespace __wrn_view_${bindingIndex++} { ${declarations} void (${expression}); }`,
|
||||
attr.value,
|
||||
);
|
||||
}
|
||||
}
|
||||
appendViewBindings(node.children, locals);
|
||||
} else if (node.type === "if") {
|
||||
for (const branch of node.branches) {
|
||||
if (branch.cond)
|
||||
append(
|
||||
`namespace __wrn_view_${bindingIndex++} { void (${branch.cond}); }`,
|
||||
branch.cond,
|
||||
);
|
||||
appendViewBindings(branch.body, locals);
|
||||
}
|
||||
} else if (node.type === "each") {
|
||||
append(`namespace __wrn_view_${bindingIndex++} { void (${node.list}); }`, node.list);
|
||||
const nested = new Set(locals);
|
||||
nested.add(node.item);
|
||||
if (node.index) nested.add(node.index);
|
||||
appendViewBindings(node.body, nested);
|
||||
appendViewBindings(node.empty, locals);
|
||||
}
|
||||
}
|
||||
};
|
||||
appendViewBindings(ast.view, new Set());
|
||||
|
||||
return {
|
||||
ast,
|
||||
@@ -464,7 +520,9 @@ function componentUsageDiagnostics(
|
||||
const shape = shapes.get(node.tag);
|
||||
if (!shape) return;
|
||||
const attributes = new Map(
|
||||
node.attrs.filter((attr) => !attr.event).map((attr) => [attr.name, attr]),
|
||||
node.attrs
|
||||
.filter((attr) => !attr.event)
|
||||
.map((attr) => [attr.name.startsWith("bind:") ? attr.name.slice(5) : attr.name, attr]),
|
||||
);
|
||||
const outputNames = new Set(shape.outputs.map((output) => output.name));
|
||||
const position = lineAt(source, `<${node.tag}`);
|
||||
@@ -501,7 +559,8 @@ function componentUsageDiagnostics(
|
||||
continue;
|
||||
}
|
||||
if (/^(?:class|id|style|slot|data-|aria-)/.test(attr.name) || attr.name === "attrs") continue;
|
||||
const prop = known.get(attr.name);
|
||||
const propName = attr.name.startsWith("bind:") ? attr.name.slice(5) : attr.name;
|
||||
const prop = known.get(propName);
|
||||
if (!prop) {
|
||||
diagnostics.push({
|
||||
code: "WRN-COMPONENT-UNKNOWN-PROP",
|
||||
|
||||
@@ -85,3 +85,30 @@ test("provides the framework context to page state expressions", () => {
|
||||
);
|
||||
expect(diagnostics.some((diagnostic) => diagnostic.code === "WRN-TYPE-2304")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects an out-of-scope name in a view event handler", () => {
|
||||
const root = app();
|
||||
const diagnostics = checkWrnSource(
|
||||
`page AccountPage {
|
||||
functions { client function save() {} }
|
||||
view { <button @click="savve(missingValue)"></button> }
|
||||
}`,
|
||||
{ appRoot: root, filePath: join(root, "app", "pages", "account.wrn") },
|
||||
);
|
||||
expect(diagnostics.some((diagnostic) => diagnostic.code === "WRN-TYPE-2304")).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts handler payload, loop locals, and the public useFetch helper", () => {
|
||||
const root = app();
|
||||
const diagnostics = checkWrnSource(
|
||||
`page AccountPage {
|
||||
state rows = [{ id: "1" }]
|
||||
functions {
|
||||
client async function save(id: string) { await useFetch("/api/rows/" + id, "POST", { id }) }
|
||||
}
|
||||
view { {#each rows as row}<button @click="save(row.id)"></button>{/each} }
|
||||
}`,
|
||||
{ appRoot: root, filePath: join(root, "app", "pages", "account.wrn") },
|
||||
);
|
||||
expect(diagnostics.filter((diagnostic) => diagnostic.code === "WRN-TYPE-2304")).toEqual([]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user