release: WRNexusJS 0.6.0

This commit is contained in:
2026-08-01 01:09:58 +05:30
parent 3e565e8d03
commit 687d345882
502 changed files with 33038 additions and 11358 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@wrnexus/typecheck",
"version": "0.6.0",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./contracts": "./src/contracts.ts",
"./project": "./src/project.ts"
},
"dependencies": {
"@wrnexus/syntax": "workspace:*",
"typescript": "^5.5.0"
}
}
+50
View File
@@ -0,0 +1,50 @@
import type { PageAst } from "@wrnexus/syntax";
function safe(name: string): string {
return /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);
}
export function componentContract(ast: PageAst): string {
const typeSource = ast.types
.map((entry) => entry.trim())
.filter(Boolean)
.join("\n\n");
const props = ast.props
.map(
(prop) =>
` readonly ${safe(prop.name)}${prop.required ? "" : "?"}: ${prop.valueType ?? "unknown"};`,
)
.join("\n");
const outputs = ast.outputs
.map(
(output) =>
` ${safe(output.name)}(${output.payload ? `${output.payload.name}${output.payload.optional ? "?" : ""}: ${output.payload.valueType}` : ""}): void;`,
)
.join("\n");
const callable = ast.runtimeFunctions
.filter((fn) => fn.runtime !== "legacy")
.map(
(fn) =>
` ${safe(fn.name)}(${fn.parameters.map((param) => `${param.name}${param.optional ? "?" : ""}: ${param.valueType ?? "unknown"}`).join(", ")}): ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")};`,
)
.join("\n");
return `${typeSource ? `${typeSource}\n\n` : ""}export interface ${ast.name}Props {\n${props}\n}\n\nexport interface ${ast.name}Outputs {\n${outputs}\n}\n\nexport interface ${ast.name}Functions {\n${callable}\n}\n\nexport interface ${ast.name}Contract {\n props: ${ast.name}Props;\n outputs: ${ast.name}Outputs;\n functions: ${ast.name}Functions;\n}\n`;
}
export function storeContract(ast: PageAst): string {
const state = ast.states
.filter((entry) => entry.runtime !== "server")
.map((entry) => ` readonly ${safe(entry.name)}: ${entry.valueType ?? "unknown"};`)
.join("\n");
const computed = ast.computed
.map((entry) => ` readonly ${safe(entry.name)}: ${entry.valueType ?? "unknown"};`)
.join("\n");
const actions = ast.runtimeFunctions
.filter((fn) => fn.runtime !== "server")
.map(
(fn) =>
` ${safe(fn.name)}(${fn.parameters.map((param) => `${param.name}${param.optional ? "?" : ""}: ${param.valueType ?? "unknown"}`).join(", ")}): ${fn.returnType ?? (fn.async ? "Promise<unknown>" : "unknown")};`,
)
.join("\n");
return `export interface ${ast.name}State {\n${state}\n}\n\nexport interface ${ast.name}Computed {\n${computed}\n}\n\nexport interface ${ast.name}Actions {\n${actions}\n}\n\nexport interface ${ast.name}Instance extends ${ast.name}State, ${ast.name}Computed, ${ast.name}Actions {\n reset(): void;\n snapshot(): Readonly<${ast.name}State>;\n}\n`;
}
+646
View File
@@ -0,0 +1,646 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, join, normalize, resolve } from "node:path";
import ts from "typescript";
import {
containsReadonlyPropMutation,
inferredRuntimeType,
parse,
runtimeTypeOf,
type PageAst,
type RuntimeFunctionDecl,
type ViewNode,
} from "@wrnexus/syntax";
import { componentContract, storeContract } from "./contracts.ts";
import { findAppRoot, loadApplicationTypes } from "./project.ts";
export { componentContract, storeContract } from "./contracts.ts";
export { findAppRoot, loadApplicationTypes } from "./project.ts";
export interface WrnTypeDiagnostic {
code: string;
category: "error" | "warning" | "info";
message: string;
file: string;
line: number;
column: number;
length: number;
expected?: string;
received?: string;
hint?: string;
related?: { file: string; line: number; column: number; message: string };
}
export interface TypecheckOptions {
filePath?: string;
appRoot?: string;
strict?: boolean;
noImplicitAny?: boolean;
strictNullChecks?: boolean;
checkRuntimeBoundaries?: boolean;
}
interface SourceMapping {
virtualStartLine: number;
virtualEndLine: number;
sourceStartLine: number;
sourceStartColumn: number;
}
function resolveImportedWrn(source: string, filePath: string, appRoot: string): string | null {
if (source.startsWith("@/")) return resolve(appRoot, "app", source.slice(2));
if (source.startsWith(".")) return resolve(dirname(filePath), source);
return null;
}
function importedWrnDeclarations(ast: PageAst, filePath: string, appRoot: string): string {
const declarations: string[] = [];
for (const entry of ast.structuredImports) {
if (entry.typeOnly || !entry.source.endsWith(".wrn")) continue;
const importedPath = resolveImportedWrn(entry.source, filePath, appRoot);
if (!importedPath || !existsSync(importedPath)) continue;
try {
const importedAst = parse(readFileSync(importedPath, "utf8"));
const isStore = importedAst.kind === "global-store" || importedAst.kind === "page-store";
declarations.push(isStore ? storeContract(importedAst) : componentContract(importedAst));
const typeName = isStore ? `${importedAst.name}Instance` : `${importedAst.name}Contract`;
if (entry.defaultImport)
declarations.push(`declare const ${entry.defaultImport}: ${typeName};`);
if (entry.namespaceImport)
declarations.push(`declare const ${entry.namespaceImport}: Record<string, unknown>;`);
for (const item of entry.namedImports) {
declarations.push(
`declare const ${item.local}: ${item.imported === importedAst.name ? typeName : "unknown"};`,
);
}
} catch {
// The parser/import diagnostic layer reports malformed or unresolved .wrn imports.
}
}
return declarations.join("\n\n");
}
export interface VirtualTypeScriptModule {
ast: PageAst;
fileName: string;
code: string;
mappings: SourceMapping[];
}
function lineAt(source: string, needle: string, occurrence = 0): { line: number; column: number } {
let index = -1;
let from = 0;
for (let count = 0; count <= occurrence; count++) {
index = source.indexOf(needle, from);
if (index < 0) return { line: 1, column: 1 };
from = index + Math.max(needle.length, 1);
}
const before = source.slice(0, index);
const parts = before.split(/\r?\n/);
return { line: parts.length, column: (parts.at(-1)?.length ?? 0) + 1 };
}
function runtimeNamespace(runtime: RuntimeFunctionDecl["runtime"]): string {
return `__wrn_${runtime}`;
}
function functionDeclaration(fn: RuntimeFunctionDecl): string {
const params = fn.parameters
.map(
(param) =>
`${param.name}${param.optional ? "?" : ""}${param.valueType ? `: ${param.valueType}` : ": unknown"}${param.default ? ` = ${param.default}` : ""}`,
)
.join(", ");
return `export ${fn.async ? "async " : ""}function ${fn.name}(${params})${fn.returnType ? `: ${fn.returnType}` : ""} {${fn.body}}`;
}
function retainedImports(ast: PageAst): string {
return ast.structuredImports
.filter((entry) => {
if (entry.source.endsWith(".wrn")) return false;
if (entry.typeOnly) return true;
return (
!entry.source.startsWith("@/components/") &&
!entry.source.startsWith("@/layouts/") &&
!entry.source.startsWith("@/stores/")
);
})
.map((entry) => entry.raw)
.join("\n");
}
export function virtualTypeScriptModule(
source: string,
filePath = "component.wrn",
appRoot = findAppRoot(filePath),
): VirtualTypeScriptModule {
const ast = parse(source);
const chunks: string[] = [];
const mappings: SourceMapping[] = [];
let virtualLine = 1;
const append = (code: string, sourceNeedle?: string, occurrence = 0): void => {
if (!code) return;
const lineCount = code.split(/\r?\n/).length;
if (sourceNeedle) {
const sourcePosition = lineAt(source, sourceNeedle, occurrence);
mappings.push({
virtualStartLine: virtualLine,
virtualEndLine: virtualLine + lineCount - 1,
sourceStartLine: sourcePosition.line,
sourceStartColumn: sourcePosition.column,
});
}
chunks.push(code);
virtualLine += lineCount;
};
append(retainedImports(ast), ast.imports[0]);
append(ast.types.join("\n\n"), ast.types[0]?.trim());
for (const prop of ast.props) {
append(`declare const ${prop.name}: Readonly<${prop.valueType ?? "unknown"}>;`, prop.name);
}
for (const state of ast.states) {
append(
`let ${state.name}${state.valueType ? `: ${state.valueType}` : ""} = (${state.expr});`,
state.name,
);
}
for (const computed of ast.computed) {
append(
`const ${computed.name}${computed.valueType ? `: ${computed.valueType}` : ""} = (${computed.expr});`,
computed.name,
);
}
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>" : "unknown")}`,
)
.join("; ");
append(`declare const output: { ${outputType} };`);
append(`declare const server: { ${serverType} };`);
append(`declare const props: Readonly<${ast.name}Props>;`);
append("declare const refs: Record<string, Element | null>;");
append(
ast.kind === "global-store" || ast.kind === "page-store"
? storeContract(ast)
: componentContract(ast),
);
append(importedWrnDeclarations(ast, filePath, appRoot));
const sharedNames = new Set(
ast.runtimeFunctions.filter((fn) => fn.runtime === "shared").map((fn) => fn.name),
);
for (const runtime of ["shared", "client", "server", "legacy"] as const) {
const functions = ast.runtimeFunctions.filter((fn) => fn.runtime === runtime);
if (!functions.length) continue;
const localNames = new Set(functions.map((fn) => fn.name));
const sharedAliases =
runtime !== "shared"
? [...sharedNames]
.filter((name) => !localNames.has(name))
.map((name) => `const ${name} = ${runtimeNamespace("shared")}.${name};`)
.join("\n")
: "";
append(`namespace ${runtimeNamespace(runtime)} {\n${sharedAliases}`);
for (const fn of functions) append(functionDeclaration(fn), fn.source);
append("}");
}
return {
ast,
fileName: filePath.replace(/\.wrn$/i, ".wrn.ts"),
code: chunks.join("\n") + "\n",
mappings,
};
}
interface ComponentShape {
name: string;
props: Array<{ name: string; type: string; required: boolean; options?: string[] }>;
outputs: Array<{ name: string; payloadType?: string }>;
}
function shapeFromAst(ast: PageAst): ComponentShape {
return {
name: ast.name,
props: ast.props.map((prop) => ({
name: prop.name,
type: prop.valueType ?? "unknown",
required: prop.required,
options: /^\s*(?:"[^"]+"\s*\|\s*)+"[^"]+"\s*$/.test(prop.valueType ?? "")
? (prop.valueType ?? "").split("|").map((part) => part.trim().replace(/^"|"$/g, ""))
: undefined,
})),
outputs: ast.outputs.map((output) => ({
name: output.name,
payloadType: output.payload?.valueType,
})),
};
}
function loadUiShapes(appRoot: string): Map<string, ComponentShape> {
const candidates = [
join(appRoot, "node_modules", "@wrnexus", "ui", "component-reference.json"),
resolve(process.cwd(), "packages", "ui", "component-reference.json"),
];
for (const candidate of candidates) {
if (!existsSync(candidate)) continue;
try {
const parsed = JSON.parse(readFileSync(candidate, "utf8")) as {
components?: Array<{
name: string;
props?: Array<{ name: string; type?: string; required?: boolean; options?: string[] }>;
outputs?: Array<{ name: string; payloadType?: string }>;
}>;
};
return new Map(
(parsed.components ?? []).map((component) => [
component.name,
{
name: component.name,
props: (component.props ?? []).map((prop) => ({
name: prop.name,
type: prop.type ?? "unknown",
required: Boolean(prop.required),
options: prop.options,
})),
outputs: component.outputs ?? [],
},
]),
);
} catch {
// Ignore a stale or malformed optional reference; import diagnostics report it separately.
}
}
return new Map();
}
function importedComponentShapes(
ast: PageAst,
filePath: string,
appRoot: string,
): Map<string, ComponentShape> {
const shapes = new Map<string, ComponentShape>();
const ui = loadUiShapes(appRoot);
for (const entry of ast.structuredImports) {
if (entry.typeOnly) continue;
if (entry.source === "@wrnexus/ui") {
for (const item of entry.namedImports) {
const shape = ui.get(item.imported);
if (shape) shapes.set(item.local, shape);
}
continue;
}
if (!entry.source.endsWith(".wrn")) continue;
const importedPath = resolveImportedWrn(entry.source, filePath, appRoot);
if (!importedPath || !existsSync(importedPath)) continue;
try {
const importedAst = parse(readFileSync(importedPath, "utf8"));
if (importedAst.kind !== "component") continue;
const shape = shapeFromAst(importedAst);
if (entry.defaultImport) shapes.set(entry.defaultImport, shape);
for (const item of entry.namedImports)
if (item.imported === importedAst.name) shapes.set(item.local, shape);
} catch {
// Syntax/import diagnostics are emitted elsewhere.
}
}
return shapes;
}
function walkView(
nodes: ViewNode[],
visit: (node: Extract<ViewNode, { type: "element" }>) => void,
): void {
for (const node of nodes) {
if (node.type === "element") {
visit(node);
walkView(node.children, visit);
} else if (node.type === "each") {
walkView(node.body, visit);
walkView(node.empty, visit);
} else if (node.type === "if") {
for (const branch of node.branches) walkView(branch.body, visit);
}
}
}
function componentUsageDiagnostics(
source: string,
ast: PageAst,
filePath: string,
appRoot: string,
): WrnTypeDiagnostic[] {
const shapes = importedComponentShapes(ast, filePath, appRoot);
const diagnostics: WrnTypeDiagnostic[] = [];
walkView(ast.view, (node) => {
const shape = shapes.get(node.tag);
if (!shape) return;
const attributes = new Map(
node.attrs.filter((attr) => !attr.event).map((attr) => [attr.name, attr]),
);
const outputNames = new Set(shape.outputs.map((output) => output.name));
const position = lineAt(source, `<${node.tag}`);
for (const prop of shape.props) {
if (prop.required && !attributes.has(prop.name)) {
diagnostics.push({
code: "WRN-COMPONENT-MISSING-PROP",
category: "error",
message: `<${node.tag}> is missing required prop '${prop.name}'.`,
file: filePath,
line: position.line,
column: position.column,
length: node.tag.length + 1,
expected: prop.type,
hint: `Add ${prop.name} with a value assignable to ${prop.type}.`,
});
}
}
const known = new Map(shape.props.map((prop) => [prop.name, prop]));
for (const attr of node.attrs) {
if (attr.event) {
if (!outputNames.has(attr.name)) {
diagnostics.push({
code: "WRN-OUTPUT-UNKNOWN-HANDLER",
category: "error",
message: `<${node.tag}> does not declare output '${attr.name}'.`,
file: filePath,
line: position.line,
column: position.column,
length: attr.name.length,
hint: "Use an output declared by the component contract.",
});
}
continue;
}
if (/^(?:class|id|style|slot|data-|aria-)/.test(attr.name) || attr.name === "attrs") continue;
const prop = known.get(attr.name);
if (!prop) {
diagnostics.push({
code: "WRN-COMPONENT-UNKNOWN-PROP",
category: "error",
message: `<${node.tag}> has unknown prop '${attr.name}'.`,
file: filePath,
line: position.line,
column: position.column,
length: attr.name.length,
hint: "Remove the prop or add it to the component declaration.",
});
continue;
}
if (attr.value.startsWith("{") && attr.value.endsWith("}")) continue;
const received = attr.boolean ? "boolean" : inferredRuntimeType(JSON.stringify(attr.value));
const expected = runtimeTypeOf(prop.type);
if (expected !== "unknown" && received !== "unknown" && expected !== received) {
diagnostics.push({
code: "WRN-COMPONENT-PROP-TYPE",
category: "error",
message: `Prop '${attr.name}' on <${node.tag}> expects ${prop.type}, received ${received}.`,
file: filePath,
line: position.line,
column: position.column,
length: attr.name.length,
expected: prop.type,
received,
});
}
if (prop.options?.length && !prop.options.includes(attr.value)) {
diagnostics.push({
code: "WRN-COMPONENT-PROP-LITERAL",
category: "error",
message: `Prop '${attr.name}' on <${node.tag}> must be one of ${prop.options.map((value) => JSON.stringify(value)).join(", ")}.`,
file: filePath,
line: position.line,
column: position.column,
length: attr.name.length,
expected: prop.type,
received: JSON.stringify(attr.value),
});
}
}
});
return diagnostics;
}
function category(value: ts.DiagnosticCategory): WrnTypeDiagnostic["category"] {
return value === ts.DiagnosticCategory.Error
? "error"
: value === ts.DiagnosticCategory.Warning
? "warning"
: "info";
}
function hostWithVirtualFiles(
files: Map<string, string>,
options: ts.CompilerOptions,
): ts.CompilerHost {
const host = ts.createCompilerHost(options, true);
const originalGet = host.getSourceFile.bind(host);
host.fileExists = (fileName) => files.has(normalize(fileName)) || ts.sys.fileExists(fileName);
host.readFile = (fileName) => files.get(normalize(fileName)) ?? ts.sys.readFile(fileName);
host.getSourceFile = (fileName, languageVersion, onError, shouldCreateNewSourceFile) => {
const text = files.get(normalize(fileName));
return text === undefined
? originalGet(fileName, languageVersion, onError, shouldCreateNewSourceFile)
: ts.createSourceFile(fileName, text, languageVersion, true, ts.ScriptKind.TS);
};
return host;
}
function mappedPosition(
virtual: VirtualTypeScriptModule,
line: number,
column: number,
): { line: number; column: number } {
const mapping = virtual.mappings.find(
(entry) => line >= entry.virtualStartLine && line <= entry.virtualEndLine,
);
if (!mapping) return { line: 1, column: 1 };
const offset = line - mapping.virtualStartLine;
return {
line: mapping.sourceStartLine + offset,
column: offset === 0 ? mapping.sourceStartColumn + Math.max(column - 1, 0) : column,
};
}
function runtimeDiagnostics(source: string, ast: PageAst, filePath: string): WrnTypeDiagnostic[] {
const diagnostics: WrnTypeDiagnostic[] = [];
const duplicateKeys = new Map<string, RuntimeFunctionDecl>();
for (const fn of ast.runtimeFunctions) {
const key = `${fn.runtime}:${fn.name}`;
const previous = duplicateKeys.get(key);
if (previous) {
const position = lineAt(source, fn.source);
const related = lineAt(source, previous.source);
diagnostics.push({
code: "WRN-FUNCTION-DUPLICATE",
category: "error",
message: `Duplicate ${fn.runtime} implementation for '${fn.name}'.`,
file: filePath,
line: position.line,
column: position.column,
length: fn.name.length,
hint: "Keep only one implementation for each function name and runtime.",
related: {
file: filePath,
line: related.line,
column: related.column,
message: "First implementation is here.",
},
});
} else duplicateKeys.set(key, fn);
const position = lineAt(source, fn.source);
const parameterNames = new Set(fn.parameters.map((parameter) => parameter.name));
for (const prop of ast.props) {
if (containsReadonlyPropMutation(fn.body, prop.name, parameterNames)) {
diagnostics.push({
code: "WRN-PROP-READONLY",
category: "error",
message: `Function '${fn.name}' attempts to mutate readonly prop '${prop.name}'.`,
file: filePath,
line: position.line,
column: position.column,
length: fn.name.length,
hint: "Copy the prop into state or call a component output instead.",
});
}
}
if (fn.runtime === "server" && /\boutput\s*\./.test(fn.body)) {
diagnostics.push({
code: "WRN-OUTPUT-SERVER-CALL",
category: "error",
message: `Server function '${fn.name}' cannot call a component output.`,
file: filePath,
line: position.line,
column: position.column,
length: fn.name.length,
hint: "Return a typed value to the client function and call output.name(payload) there.",
});
}
if (
fn.runtime === "client" &&
/\b(?:Bun|process|Deno|ctx\.(?:db|request|req))\b/.test(fn.body)
) {
diagnostics.push({
code: "WRN-CLIENT-SERVER-API",
category: "error",
message: `Client function '${fn.name}' references a server-only API.`,
file: filePath,
line: position.line,
column: position.column,
length: fn.name.length,
hint: "Move server work to a server function and call it through server.name(...).",
});
}
if (
fn.runtime === "server" &&
/\b(?:window|document|localStorage|sessionStorage|navigator)\b/.test(fn.body)
) {
diagnostics.push({
code: "WRN-SERVER-BROWSER-API",
category: "error",
message: `Server function '${fn.name}' references a browser-only API.`,
file: filePath,
line: position.line,
column: position.column,
length: fn.name.length,
hint: "Move browser work to a client function.",
});
}
}
return diagnostics;
}
export function checkWrnSource(
source: string,
options: TypecheckOptions = {},
): WrnTypeDiagnostic[] {
const filePath = resolve(options.filePath ?? "component.wrn");
const appRoot = options.appRoot ?? findAppRoot(filePath);
let virtual: VirtualTypeScriptModule;
try {
virtual = virtualTypeScriptModule(source, filePath, appRoot);
} catch (error) {
return [
{
code: "WRN-TYPE-PARSE",
category: "error",
message: error instanceof Error ? error.message : String(error),
file: filePath,
line: 1,
column: 1,
length: 1,
},
];
}
const appTypes = loadApplicationTypes(appRoot);
const files = new Map<string, string>();
files.set(normalize(virtual.fileName), virtual.code);
for (const [name, text] of appTypes.files) files.set(normalize(name), text);
const compilerOptions: ts.CompilerOptions = {
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
strict: options.strict ?? true,
noImplicitAny: options.noImplicitAny ?? true,
strictNullChecks: options.strictNullChecks ?? true,
skipLibCheck: true,
allowImportingTsExtensions: true,
allowArbitraryExtensions: true,
noEmit: true,
baseUrl: appRoot,
paths: { "@/*": ["app/*"] },
lib: ["lib.esnext.d.ts", "lib.dom.d.ts"],
};
const rootNames = [virtual.fileName, ...appTypes.files.keys()];
const program = ts.createProgram(
rootNames,
compilerOptions,
hostWithVirtualFiles(files, compilerOptions),
);
const tsDiagnostics = ts.getPreEmitDiagnostics(program).map((diagnostic): WrnTypeDiagnostic => {
const file = diagnostic.file;
const start = diagnostic.start ?? 0;
const virtualPosition = file?.getLineAndCharacterOfPosition(start) ?? { line: 0, character: 0 };
const isVirtual = normalize(file?.fileName ?? "") === normalize(virtual.fileName);
const sourcePosition = isVirtual
? mappedPosition(virtual, virtualPosition.line + 1, virtualPosition.character + 1)
: { line: virtualPosition.line + 1, column: virtualPosition.character + 1 };
return {
code: `WRN-TYPE-${diagnostic.code}`,
category: category(diagnostic.category),
message: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
file: isVirtual ? filePath : (file?.fileName ?? filePath),
line: sourcePosition.line,
column: sourcePosition.column,
length: diagnostic.length ?? 1,
hint: "Fix the TypeScript contract or expression in the related .wrn declaration.",
};
});
return [
...(options.checkRuntimeBoundaries === false
? []
: runtimeDiagnostics(source, virtual.ast, filePath)),
...componentUsageDiagnostics(source, virtual.ast, filePath, appRoot),
...tsDiagnostics,
];
}
export function checkWrnFile(
filePath: string,
options: Omit<TypecheckOptions, "filePath"> = {},
): WrnTypeDiagnostic[] {
return checkWrnSource(readFileSync(filePath, "utf8"), { ...options, filePath });
}
+37
View File
@@ -0,0 +1,37 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
export interface ApplicationTypes {
root: string;
globalFile?: string;
files: Map<string, string>;
}
function walk(dir: string, out: string[]): void {
if (!existsSync(dir)) return;
for (const name of readdirSync(dir)) {
const path = join(dir, name);
const stat = statSync(path);
if (stat.isDirectory()) walk(path, out);
else if (/\.(?:ts|d\.ts)$/.test(name)) out.push(path);
}
}
export function findAppRoot(filePath: string): string {
let current = resolve(dirname(filePath));
while (true) {
if (existsSync(join(current, "app"))) return current;
const parent = dirname(current);
if (parent === current) return resolve(dirname(filePath));
current = parent;
}
}
export function loadApplicationTypes(appRoot: string): ApplicationTypes {
const typesRoot = join(appRoot, "app", "types");
const paths: string[] = [];
walk(typesRoot, paths);
const files = new Map(paths.map((path) => [path, readFileSync(path, "utf8")]));
const globalFile = join(typesRoot, "global.d.ts");
return { root: typesRoot, ...(files.has(globalFile) ? { globalFile } : {}), files };
}
@@ -0,0 +1,62 @@
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 fixture(): string {
const root = mkdtempSync(join(tmpdir(), "wrn-v060-contracts-"));
mkdirSync(join(root, "app", "components"), { recursive: true });
mkdirSync(join(root, "app", "stores", "global"), { recursive: true });
mkdirSync(join(root, "app", "types"), { recursive: true });
writeFileSync(
join(root, "app", "types", "global.d.ts"),
"declare interface AppMarker { ready: boolean }\n",
);
writeFileSync(
join(root, "app", "components", "UserCard.wrn"),
`component UserCard {
props { name: string role: "admin" | "member" = "member" }
outputs { select(payload: string) }
view { <button>{name}</button> }
}`,
);
writeFileSync(
join(root, "app", "stores", "global", "counter.wrn"),
`global store CounterStore {
state { count: number = 0 }
functions { client function increment(amount: number): void { count += amount } }
}`,
);
return root;
}
test("checks imported component props and output names", () => {
const root = fixture();
const filePath = join(root, "app", "pages", "home.wrn");
const diagnostics = checkWrnSource(
`import UserCard from "@/components/UserCard.wrn"
page Home {
view { <UserCard role="owner" unknown="x" @missing='noop(payload)' /> }
}`,
{ appRoot: root, filePath },
);
expect(diagnostics.some((item) => item.code === "WRN-COMPONENT-MISSING-PROP")).toBe(true);
expect(diagnostics.some((item) => item.code === "WRN-COMPONENT-UNKNOWN-PROP")).toBe(true);
expect(diagnostics.some((item) => item.code === "WRN-COMPONENT-PROP-LITERAL")).toBe(true);
expect(diagnostics.some((item) => item.code === "WRN-OUTPUT-UNKNOWN-HANDLER")).toBe(true);
});
test("types imported store actions", () => {
const root = fixture();
const filePath = join(root, "app", "pages", "home.wrn");
const diagnostics = checkWrnSource(
`import counterStore from "@/stores/global/counter.wrn"
page Home {
functions { client function update(): void { counterStore.increment("wrong") } }
view { <button></button> }
}`,
{ appRoot: root, filePath },
);
expect(diagnostics.some((item) => item.code === "WRN-TYPE-2345")).toBe(true);
});
+57
View File
@@ -0,0 +1,57 @@
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-"));
mkdirSync(join(root, "app", "types"), { recursive: true });
writeFileSync(
join(root, "app", "types", "user.ts"),
"export interface User { id: string; name: string }\n",
);
writeFileSync(
join(root, "app", "types", "global.d.ts"),
"declare interface RequestError { message: string }\n",
);
return root;
}
test("loads app types and maps output payload errors back to WRN source", () => {
const root = app();
const source = `import type { User } from "@/types/user.ts"
component Demo {
props { user: User }
outputs { confirm(payload: User) }
functions {
client function save(value: User): void {
output.confirm({ id: 1, name: value.name })
}
}
view { <button></button> }
}`;
const diagnostics = checkWrnSource(source, {
appRoot: root,
filePath: join(root, "app", "components", "Demo.wrn"),
});
expect(diagnostics.some((diagnostic) => diagnostic.code === "WRN-TYPE-2322")).toBe(true);
expect(diagnostics.find((diagnostic) => diagnostic.code === "WRN-TYPE-2322")?.line).toBe(7);
});
test("allows the same function name in client and server runtimes", () => {
const root = app();
const diagnostics = checkWrnSource(
`component Demo {
functions {
client async function save(value: string): Promise<void> { await server.save(value) }
server async function save(value: string): Promise<void> {}
}
view { <div></div> }
}`,
{ appRoot: root, filePath: join(root, "app", "components", "Demo.wrn") },
);
expect(
diagnostics.filter((diagnostic) => diagnostic.code === "WRN-FUNCTION-DUPLICATE"),
).toHaveLength(0);
});