release: WRNexusJS 0.8.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/typecheck",
|
||||
"version": "0.8.3",
|
||||
"version": "0.8.4",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
+129
-29
@@ -139,9 +139,13 @@ function functionDeclaration(fn: RuntimeFunctionDecl): string {
|
||||
const params = fn.parameters
|
||||
.map(
|
||||
(param) =>
|
||||
`${param.name}${param.optional ? "?" : ""}${param.valueType ? `: ${param.valueType}` : param.name === "event" ? ": CustomEvent<any> & { target: HTMLElement }" : ": unknown"}${param.default ? ` = ${param.default}` : ""}`,
|
||||
`${param.name}${param.optional ? "?" : ""}${param.valueType ? `: ${param.valueType}` : param.name === "event" ? ": CustomEvent<any> & { target: HTMLElement }" : ": any"}${param.default ? ` = ${param.default}` : ""}`,
|
||||
)
|
||||
.join(", ");
|
||||
// An omitted WRN parameter type follows JavaScript semantics. Emitting it as
|
||||
// explicit `any` prevents the virtual TypeScript document from inventing
|
||||
// false errors for valid dynamic handlers such as output[type](payload).
|
||||
// Developers can opt into strict checking by declaring the parameter type.
|
||||
return `export ${fn.async ? "async " : ""}function ${fn.name}(${params})${fn.returnType ? `: ${fn.returnType}` : ""} {${fn.body}}`;
|
||||
}
|
||||
|
||||
@@ -160,6 +164,53 @@ function retainedImports(ast: PageAst): string {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const RESERVED_BINDING_NAMES = new Set([
|
||||
"await",
|
||||
"break",
|
||||
"case",
|
||||
"catch",
|
||||
"class",
|
||||
"const",
|
||||
"continue",
|
||||
"debugger",
|
||||
"default",
|
||||
"delete",
|
||||
"do",
|
||||
"else",
|
||||
"enum",
|
||||
"export",
|
||||
"extends",
|
||||
"false",
|
||||
"finally",
|
||||
"for",
|
||||
"function",
|
||||
"if",
|
||||
"import",
|
||||
"in",
|
||||
"instanceof",
|
||||
"let",
|
||||
"new",
|
||||
"null",
|
||||
"return",
|
||||
"static",
|
||||
"super",
|
||||
"switch",
|
||||
"this",
|
||||
"throw",
|
||||
"true",
|
||||
"try",
|
||||
"typeof",
|
||||
"var",
|
||||
"void",
|
||||
"while",
|
||||
"with",
|
||||
"yield",
|
||||
]);
|
||||
|
||||
function safeBindingName(name: string): boolean {
|
||||
return /^[A-Za-z_$][\w$]*$/.test(name) && !RESERVED_BINDING_NAMES.has(name);
|
||||
}
|
||||
|
||||
export function virtualTypeScriptModule(
|
||||
source: string,
|
||||
filePath = "component.wrn",
|
||||
@@ -190,6 +241,10 @@ export function virtualTypeScriptModule(
|
||||
append(ast.types.join("\n\n"), ast.types[0]?.trim());
|
||||
|
||||
for (const prop of ast.props) {
|
||||
// JavaScript keywords such as the conventional `class` component prop are
|
||||
// valid WRN prop names but cannot be emitted as standalone TS bindings.
|
||||
// They remain available through the typed `props` object.
|
||||
if (!safeBindingName(prop.name)) continue;
|
||||
append(`declare const ${prop.name}: Readonly<${prop.valueType ?? "unknown"}>;`, prop.name);
|
||||
}
|
||||
for (const state of ast.states) {
|
||||
@@ -218,7 +273,7 @@ export function virtualTypeScriptModule(
|
||||
`${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 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;`,
|
||||
@@ -439,7 +494,15 @@ function componentUsageDiagnostics(
|
||||
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) {
|
||||
const compatibleLiteral =
|
||||
(expected === "boolean" && /^(?:true|false|1|0|yes|no|on|off)?$/i.test(attr.value)) ||
|
||||
(expected === "number" && attr.value.trim() !== "" && Number.isFinite(Number(attr.value)));
|
||||
if (
|
||||
expected !== "unknown" &&
|
||||
received !== "unknown" &&
|
||||
expected !== received &&
|
||||
!compatibleLiteral
|
||||
) {
|
||||
diagnostics.push({
|
||||
code: "WRN-COMPONENT-PROP-TYPE",
|
||||
category: "error",
|
||||
@@ -481,8 +544,15 @@ function category(value: ts.DiagnosticCategory): WrnTypeDiagnostic["category"] {
|
||||
function hostWithVirtualFiles(
|
||||
files: Map<string, string>,
|
||||
options: ts.CompilerOptions,
|
||||
standardLibraryDirectory?: string,
|
||||
): ts.CompilerHost {
|
||||
const host = ts.createCompilerHost(options, true);
|
||||
if (standardLibraryDirectory) {
|
||||
host.getDefaultLibFileName = (compilerOptions) =>
|
||||
join(standardLibraryDirectory, ts.getDefaultLibFileName(compilerOptions));
|
||||
(host as ts.CompilerHost & { getDefaultLibLocation?: () => string }).getDefaultLibLocation =
|
||||
() => standardLibraryDirectory;
|
||||
}
|
||||
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);
|
||||
@@ -495,15 +565,32 @@ function hostWithVirtualFiles(
|
||||
return host;
|
||||
}
|
||||
|
||||
function resolveStandardLibraryDirectory(
|
||||
appRoot: string,
|
||||
options: ts.CompilerOptions,
|
||||
): string | null {
|
||||
const candidates = [
|
||||
dirname(ts.getDefaultLibFilePath(options)),
|
||||
join(appRoot, "node_modules", "typescript", "lib"),
|
||||
join(process.cwd(), "node_modules", "typescript", "lib"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if ((options.lib ?? []).every((name) => ts.sys.fileExists(join(candidate, name)))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mappedPosition(
|
||||
virtual: VirtualTypeScriptModule,
|
||||
line: number,
|
||||
column: number,
|
||||
): { line: number; column: number } {
|
||||
): { line: number; column: number } | null {
|
||||
const mapping = virtual.mappings.find(
|
||||
(entry) => line >= entry.virtualStartLine && line <= entry.virtualEndLine,
|
||||
);
|
||||
if (!mapping) return { line: 1, column: 1 };
|
||||
if (!mapping) return null;
|
||||
const offset = line - mapping.virtualStartLine;
|
||||
return {
|
||||
line: mapping.sourceStartLine + offset,
|
||||
@@ -644,30 +731,43 @@ export function checkWrnSource(
|
||||
lib: ["lib.esnext.d.ts", "lib.dom.d.ts", "lib.dom.iterable.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.",
|
||||
};
|
||||
});
|
||||
const standardLibraryDirectory = resolveStandardLibraryDirectory(appRoot, compilerOptions);
|
||||
|
||||
const tsDiagnostics: WrnTypeDiagnostic[] = [];
|
||||
if (standardLibraryDirectory) {
|
||||
const program = ts.createProgram(
|
||||
rootNames,
|
||||
compilerOptions,
|
||||
hostWithVirtualFiles(files, compilerOptions, standardLibraryDirectory),
|
||||
);
|
||||
for (const diagnostic of ts.getPreEmitDiagnostics(program)) {
|
||||
const file = diagnostic.file;
|
||||
if (!file) continue;
|
||||
const normalizedFile = normalize(file.fileName);
|
||||
const isVirtual = normalizedFile === normalize(virtual.fileName);
|
||||
const isApplicationType = files.has(normalizedFile);
|
||||
if (!isVirtual && !isApplicationType) continue;
|
||||
|
||||
const start = diagnostic.start ?? 0;
|
||||
const virtualPosition = file.getLineAndCharacterOfPosition(start);
|
||||
const sourcePosition = isVirtual
|
||||
? mappedPosition(virtual, virtualPosition.line + 1, virtualPosition.character + 1)
|
||||
: { line: virtualPosition.line + 1, column: virtualPosition.character + 1 };
|
||||
// Synthetic declarations without a source mapping are implementation
|
||||
// details of the virtual document and must not appear as editor errors.
|
||||
if (!sourcePosition) continue;
|
||||
tsDiagnostics.push({
|
||||
code: `WRN-TYPE-${diagnostic.code}`,
|
||||
category: category(diagnostic.category),
|
||||
message: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
|
||||
file: isVirtual ? filePath : file.fileName,
|
||||
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
|
||||
? []
|
||||
|
||||
@@ -60,3 +60,26 @@ page Home {
|
||||
);
|
||||
expect(diagnostics.some((item) => item.code === "WRN-TYPE-2345")).toBe(true);
|
||||
}, 15_000);
|
||||
|
||||
test("accepts WRN boolean string literals and reserved class props", () => {
|
||||
const root = fixture();
|
||||
const componentPath = join(root, "app", "components", "Toggle.wrn");
|
||||
writeFileSync(
|
||||
componentPath,
|
||||
`component Toggle {
|
||||
props { enabled: boolean = false class: string = "" }
|
||||
view { <button class='{class}'></button> }
|
||||
}`,
|
||||
);
|
||||
const filePath = join(root, "app", "pages", "home.wrn");
|
||||
const diagnostics = checkWrnSource(
|
||||
`import Toggle from "@/components/Toggle.wrn"
|
||||
page Home {
|
||||
view { <Toggle enabled="true" class="demo" /> }
|
||||
}`,
|
||||
{ appRoot: root, filePath },
|
||||
);
|
||||
expect(diagnostics.some((item) => item.code === "WRN-COMPONENT-PROP-TYPE")).toBe(false);
|
||||
expect(diagnostics.some((item) => item.code === "WRN-TYPE-1389")).toBe(false);
|
||||
expect(diagnostics.some((item) => item.code === "WRN-TYPE-1005")).toBe(false);
|
||||
}, 15_000);
|
||||
|
||||
@@ -55,3 +55,21 @@ test("allows the same function name in client and server runtimes", () => {
|
||||
diagnostics.filter((diagnostic) => diagnostic.code === "WRN-FUNCTION-DUPLICATE"),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("treats omitted runtime parameter types as dynamic JavaScript values", () => {
|
||||
const root = app();
|
||||
const diagnostics = checkWrnSource(
|
||||
`component Demo {
|
||||
outputs { change(payload: { value?: string; sourceEvent?: Event }) }
|
||||
functions {
|
||||
client function forward(type, payload) {
|
||||
const detail = payload || {}
|
||||
output[type]({ value: detail.value || "", sourceEvent: detail.sourceEvent })
|
||||
}
|
||||
}
|
||||
view { <button @click="forward('change', payload)"></button> }
|
||||
}`,
|
||||
{ appRoot: root, filePath: join(root, "app", "components", "Demo.wrn") },
|
||||
);
|
||||
expect(diagnostics.filter((diagnostic) => diagnostic.code.startsWith("WRN-TYPE-"))).toEqual([]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user