release: WRNexusJS 0.6.0
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user