release: WRNexusJS 0.6.0
This commit is contained in:
@@ -22,6 +22,9 @@ export interface WrnDiagnostic {
|
||||
hint?: string;
|
||||
file?: string;
|
||||
position?: WrnSourcePosition;
|
||||
expected?: string;
|
||||
received?: string;
|
||||
related?: Array<{ file?: string; message: string; position?: WrnSourcePosition }>;
|
||||
}
|
||||
|
||||
export interface DiagnoseOptions {
|
||||
@@ -29,6 +32,95 @@ export interface DiagnoseOptions {
|
||||
accessibility?: boolean;
|
||||
}
|
||||
|
||||
function maskJavaScriptTrivia(source: string): string {
|
||||
let result = "";
|
||||
let index = 0;
|
||||
let quote: "'" | '"' | "`" | null = null;
|
||||
let lineComment = false;
|
||||
let blockComment = false;
|
||||
|
||||
while (index < source.length) {
|
||||
const char = source[index]!;
|
||||
const next = source[index + 1];
|
||||
|
||||
if (lineComment) {
|
||||
if (char === "\n") {
|
||||
lineComment = false;
|
||||
result += "\n";
|
||||
} else result += " ";
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (blockComment) {
|
||||
if (char === "*" && next === "/") {
|
||||
result += " ";
|
||||
index += 2;
|
||||
blockComment = false;
|
||||
} else {
|
||||
result += char === "\n" ? "\n" : " ";
|
||||
index++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (quote) {
|
||||
if (char === "\\") {
|
||||
result += " ";
|
||||
index += Math.min(2, source.length - index);
|
||||
} else if (char === quote) {
|
||||
result += " ";
|
||||
index++;
|
||||
quote = null;
|
||||
} else {
|
||||
result += char === "\n" ? "\n" : " ";
|
||||
index++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "/") {
|
||||
result += " ";
|
||||
index += 2;
|
||||
lineComment = true;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "*") {
|
||||
result += " ";
|
||||
index += 2;
|
||||
blockComment = true;
|
||||
continue;
|
||||
}
|
||||
if (char === "'" || char === '"' || char === "`") {
|
||||
quote = char;
|
||||
result += " ";
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
result += char;
|
||||
index++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function containsReadonlyPropMutation(
|
||||
body: string,
|
||||
propName: string,
|
||||
parameterNames: Set<string>,
|
||||
): boolean {
|
||||
const code = maskJavaScriptTrivia(body);
|
||||
const escaped = propName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const operator = String.raw`(?:\+\+|--|(?:\*\*|&&|\|\||\?\?|[+\-*/%&|^])?=(?!=|>))`;
|
||||
|
||||
if (new RegExp(String.raw`\bprops\.${escaped}\s*${operator}`).test(code)) return true;
|
||||
if (parameterNames.has(propName)) return false;
|
||||
if (new RegExp(String.raw`\b(?:const|let|var)\s+${escaped}\b`).test(code)) return false;
|
||||
|
||||
return new RegExp(String.raw`(?:^|[^\w$.])${escaped}\s*${operator}`, "m").test(code);
|
||||
}
|
||||
|
||||
export function positionAt(source: string, offset: number): WrnSourcePosition {
|
||||
const safe = Math.max(0, Math.min(offset, source.length));
|
||||
const before = source.slice(0, safe);
|
||||
@@ -155,6 +247,100 @@ function astDiagnostics(ast: PageAst, options: DiagnoseOptions): WrnDiagnostic[]
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
|
||||
const outputs = new Set(ast.outputs.map((output) => output.name));
|
||||
for (const fn of ast.runtimeFunctions) {
|
||||
for (const call of fn.body.matchAll(/\boutput\.([A-Za-z_$][\w$]*)\s*\(/g)) {
|
||||
const outputName = call[1]!;
|
||||
if (fn.runtime === "server") {
|
||||
diagnostics.push({
|
||||
code: "WRN-OUTPUT-SERVER-CALL",
|
||||
severity: "error",
|
||||
message: `Server function '${fn.name}' cannot call output.${outputName}().`,
|
||||
hint: "Return a typed value to the browser and call the output from a client function.",
|
||||
file: options.file,
|
||||
});
|
||||
} else if (!outputs.has(outputName)) {
|
||||
diagnostics.push({
|
||||
code: "WRN-OUTPUT-UNKNOWN",
|
||||
severity: "error",
|
||||
message: `Unknown output '${outputName}' called from '${fn.name}'.`,
|
||||
hint: `Declare ${outputName}(payload) inside outputs { ... }.`,
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (fn.runtime === "client" && /\b(?:process|Bun|Deno|__dirname|require)\b/.test(fn.body)) {
|
||||
diagnostics.push({
|
||||
code: "WRN-CLIENT-SERVER-API",
|
||||
severity: "error",
|
||||
message: `Client function '${fn.name}' references a server-only API.`,
|
||||
hint: "Move that operation into a server function and call it through server.name(...).",
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
if (
|
||||
fn.runtime === "server" &&
|
||||
/\b(?:window|document|localStorage|sessionStorage|navigator)\b/.test(fn.body)
|
||||
) {
|
||||
diagnostics.push({
|
||||
code: "WRN-SERVER-BROWSER-API",
|
||||
severity: "error",
|
||||
message: `Server function '${fn.name}' references a browser-only API.`,
|
||||
hint: "Move that code into a client function.",
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
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",
|
||||
severity: "error",
|
||||
message: `Function '${fn.name}' attempts to mutate readonly prop '${prop.name}'.`,
|
||||
hint: "Copy the prop into state before mutating it.",
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const state of ast.states) {
|
||||
if (
|
||||
state.runtime === "shared" &&
|
||||
/^(?:new\s+(?:Map|Set|WeakMap|WeakSet)|(?:async\s+)?function\b|.*=>)/.test(state.expr.trim())
|
||||
) {
|
||||
diagnostics.push({
|
||||
code: "WRN-STATE-NON-SERIALIZABLE",
|
||||
severity: "error",
|
||||
message: `Shared state '${state.name}' is not safely serializable.`,
|
||||
hint: "Use JSON-compatible data or move the value into client/server state.",
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (ast.persist) {
|
||||
const stateNames = new Set(
|
||||
ast.states.filter((state) => state.runtime !== "server").map((state) => state.name),
|
||||
);
|
||||
for (const name of ast.persist.include)
|
||||
if (!stateNames.has(name))
|
||||
diagnostics.push({
|
||||
code: "WRN-PERSIST-UNKNOWN-FIELD",
|
||||
severity: "error",
|
||||
message: `Persist include references unknown or server-only state '${name}'.`,
|
||||
hint: "Persist only declared shared/client state fields.",
|
||||
file: options.file,
|
||||
});
|
||||
for (const name of ast.persist.include)
|
||||
if (/token|password|secret|otp|api.?key/i.test(name))
|
||||
diagnostics.push({
|
||||
code: "WRN-PERSIST-SENSITIVE",
|
||||
severity: "error",
|
||||
message: `Sensitive field '${name}' cannot be persisted.`,
|
||||
hint: "Remove secrets, tokens, passwords, OTPs, and API keys from persistence.",
|
||||
file: options.file,
|
||||
});
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user