Files
WRNexusJS/packages/syntax/src/diagnostics.ts
T
2026-08-01 10:04:42 +05:30

493 lines
16 KiB
TypeScript

import { parse, ParseError, type PageAst, type ViewNode } from "./parser.ts";
import {
WRN_DIAGNOSTIC_CODES,
WRN_HYDRATION_STRATEGIES,
WRN_RUNTIME_TARGETS,
type WrnHydrationStrategy,
type WrnRuntimeTarget,
} from "./spec.ts";
export type WrnDiagnosticSeverity = "error" | "warning" | "info";
export interface WrnSourcePosition {
offset: number;
line: number;
column: number;
}
export interface WrnDiagnostic {
code: string;
severity: WrnDiagnosticSeverity;
message: string;
hint?: string;
file?: string;
position?: WrnSourcePosition;
expected?: string;
received?: string;
related?: Array<{ file?: string; message: string; position?: WrnSourcePosition }>;
}
export interface DiagnoseOptions {
file?: string;
accessibility?: boolean;
}
function stripAsciiControlAndSpace(value: string): string {
let result = "";
for (const character of value) {
if (character.charCodeAt(0) > 0x20) result += character;
}
return result;
}
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);
const lines = before.split(/\r?\n/);
return { offset: safe, line: lines.length, column: (lines.at(-1)?.length ?? 0) + 1 };
}
function offsetFromMessage(message: string): number | undefined {
const match = /offset\s+(\d+)/i.exec(message);
return match ? Number(match[1]) : undefined;
}
export function classifyParseError(message: string): string {
if (/Expected 'page', 'component', or 'layout'/.test(message)) return WRN_DIAGNOSTIC_CODES.root;
if (/Unknown (?:page|component|layout|ssr|client) member/.test(message)) {
return WRN_DIAGNOSTIC_CODES.member;
}
if (/prop initializer|Expected eq/.test(message)) return WRN_DIAGNOSTIC_CODES.propInitializer;
if (/State '.+' requires an initializer/.test(message)) {
return WRN_DIAGNOSTIC_CODES.stateInitializer;
}
if (/Cannot watch undeclared state/.test(message)) return WRN_DIAGNOSTIC_CODES.watchUndeclared;
return WRN_DIAGNOSTIC_CODES.parse;
}
export function diagnosticFromError(
source: string,
error: unknown,
options: DiagnoseOptions = {},
): WrnDiagnostic {
const message = error instanceof Error ? error.message : String(error);
const offset =
error instanceof ParseError && error.offset !== undefined
? error.offset
: offsetFromMessage(message);
return {
code: error instanceof ParseError ? error.code : classifyParseError(message),
severity: "error",
message,
file: options.file,
...(offset === undefined ? {} : { position: positionAt(source, offset) }),
};
}
function walk(nodes: ViewNode[], visit: (node: ViewNode) => void): void {
for (const node of nodes) {
visit(node);
if (node.type === "element") walk(node.children, visit);
else if (node.type === "each") {
walk(node.body, visit);
walk(node.empty, visit);
} else if (node.type === "if") {
for (const branch of node.branches) walk(branch.body, visit);
}
}
}
function astDiagnostics(ast: PageAst, options: DiagnoseOptions): WrnDiagnostic[] {
const diagnostics: WrnDiagnostic[] = [];
const seen = new Map<string, string>();
for (const [kind, declarations] of [
["prop", ast.props],
["state", ast.states],
["computed", ast.computed],
] as const) {
for (const declaration of declarations) {
const previous = seen.get(declaration.name);
if (previous) {
diagnostics.push({
code: WRN_DIAGNOSTIC_CODES.duplicateSymbol,
severity: "error",
message: `Duplicate symbol '${declaration.name}' (${previous} and ${kind}).`,
hint: "Rename one declaration so every prop, state, and computed value is unique.",
file: options.file,
});
} else {
seen.set(declaration.name, kind);
}
}
}
if (ast.hydrate && !isHydrationStrategy(ast.hydrate)) {
diagnostics.push({
code: WRN_DIAGNOSTIC_CODES.invalidHydration,
severity: "error",
message: `Unknown hydration strategy '${ast.hydrate}'.`,
hint: "Use load, idle, visible, interaction, none, or media:<query>.",
file: options.file,
});
}
if (ast.runtime && !isRuntimeTarget(ast.runtime)) {
diagnostics.push({
code: WRN_DIAGNOSTIC_CODES.invalidRuntime,
severity: "error",
message: `Unknown runtime target '${ast.runtime}'.`,
hint: "Use server, client, or universal.",
file: options.file,
});
}
let interactive = ast.states.length > 0 || ast.effects.length > 0 || ast.watches.length > 0;
const urlAttributes = new Set([
"href",
"src",
"action",
"formaction",
"poster",
"cite",
"background",
"xlink:href",
]);
walk(ast.view, (node) => {
if (node.type !== "element") return;
if (node.attrs.some((attribute) => attribute.event)) interactive = true;
const tag = node.tag.toLowerCase();
for (const attribute of node.attrs) {
if (attribute.event || attribute.boolean || !urlAttributes.has(attribute.name.toLowerCase()))
continue;
if (attribute.value.includes("{")) continue;
const value = stripAsciiControlAndSpace(attribute.value.trim()).toLowerCase();
if (
/^(?:javascript|vbscript|file):/.test(value) ||
/^data:(?!image\/(?:png|gif|jpeg|webp|avif);)/.test(value)
) {
diagnostics.push({
code: "WRN-SEC-UNSAFE-URL",
severity: "error",
message: `Unsafe URL protocol in ${attribute.name} on <${node.tag}>.`,
hint: "Use a relative URL, https:, mailto:, tel:, or a framework-validated URL helper.",
file: options.file,
});
}
}
if (tag === "a") {
const target = node.attrs.find((attribute) => attribute.name === "target")?.value;
const rel = node.attrs.find((attribute) => attribute.name === "rel")?.value ?? "";
if (target === "_blank" && !/\bnoopener\b/i.test(rel)) {
diagnostics.push({
code: "WRN-SEC-BLANK-REL",
severity: "warning",
message: "A target=_blank link should include rel=noopener.",
hint: 'Add rel="noopener noreferrer".',
file: options.file,
});
}
}
if (!options.accessibility) return;
if (tag === "img") {
if (!node.attrs.some((attribute) => attribute.name === "alt")) {
diagnostics.push({
code: WRN_DIAGNOSTIC_CODES.accessibility,
severity: "warning",
message: "Image is missing an alt attribute.",
hint: 'Add alt text, or alt="" for a decorative image.',
file: options.file,
});
}
const hasWidth = node.attrs.some((attribute) => attribute.name === "width");
const hasHeight = node.attrs.some((attribute) => attribute.name === "height");
if (!hasWidth || !hasHeight) {
diagnostics.push({
code: "WRN-PERF-IMAGE-DIMENSIONS",
severity: "warning",
message: "Image width and height are required to prevent layout shifts.",
hint: "Declare intrinsic width and height, or use @wrnexus/image.",
file: options.file,
});
}
}
});
if (ast.runtime === "server" && interactive) {
diagnostics.push({
code: WRN_DIAGNOSTIC_CODES.serverInteractive,
severity: "error",
message:
"A server-only WRN root cannot contain client state, effects, watches, or event handlers.",
hint: 'Use runtime = "universal" or remove interactive behavior.',
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,
});
}
if (
fn.runtime !== "server" &&
/\b(?:eval\s*\(|new\s+Function\s*\(|document\.write\s*\(|\.innerHTML\s*=|\.outerHTML\s*=|insertAdjacentHTML\s*\()/.test(
fn.body,
)
) {
diagnostics.push({
code: "WRN-SEC-DOM-SINK",
severity: "error",
message: `Client function '${fn.name}' uses an unsafe dynamic-code or HTML sink.`,
hint: "Use compiled templates, textContent, typed outputs, or a reviewed TrustedHTML sanitizer.",
file: options.file,
});
}
if (fn.runtime !== "server" && /\b(?:setTimeout|setInterval)\s*\(\s*["'`]/.test(fn.body)) {
diagnostics.push({
code: "WRN-SEC-STRING-TIMER",
severity: "error",
message: `Client function '${fn.name}' passes a string to a timer.`,
hint: "Pass a function instead of executable text.",
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 (
state.runtime !== "server" &&
/\b(?:process\.env|Bun\.env|Deno\.env|ctx\.env|import\.meta\.env)\b/.test(state.expr)
) {
diagnostics.push({
code: "WRN-SEC-SERVER-SECRET-SOURCE",
severity: "error",
message: `Browser-visible state '${state.name}' reads from a server environment source.`,
hint: "Move environment-backed values into server state and return only an explicitly safe result.",
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;
}
export function diagnose(source: string, options: DiagnoseOptions = {}): WrnDiagnostic[] {
try {
return astDiagnostics(parse(source), options);
} catch (error) {
return [diagnosticFromError(source, error, options)];
}
}
export function assertValidAst(ast: PageAst, options: DiagnoseOptions = {}): void {
const errors = astDiagnostics(ast, options).filter(
(diagnostic) => diagnostic.severity === "error",
);
if (!errors.length) return;
const first = errors[0]!;
throw new ParseError(first.message, first.code);
}
export function isHydrationStrategy(value: string): value is WrnHydrationStrategy {
return (
(WRN_HYDRATION_STRATEGIES as readonly string[]).includes(value) ||
(value.startsWith("media:") && value.length > "media:".length)
);
}
export function isRuntimeTarget(value: string): value is WrnRuntimeTarget {
return (WRN_RUNTIME_TARGETS as readonly string[]).includes(value);
}
export function formatDiagnostic(source: string, diagnostic: WrnDiagnostic): string {
const location = diagnostic.position
? `${diagnostic.file ?? "<inline .wrn>"}:${diagnostic.position.line}:${diagnostic.position.column}`
: (diagnostic.file ?? "<inline .wrn>");
const lines = [
`${diagnostic.code} ${diagnostic.severity.toUpperCase()}`,
"",
diagnostic.message,
"",
location,
];
if (diagnostic.position) {
const sourceLine = source.split(/\r?\n/)[diagnostic.position.line - 1] ?? "";
lines.push("", sourceLine, `${" ".repeat(Math.max(0, diagnostic.position.column - 1))}^`);
}
if (diagnostic.hint) lines.push("", `Hint: ${diagnostic.hint}`);
return lines.join("\n");
}