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, ): 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(); 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:.", 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.name === "for" || attribute.name === "key")) { diagnostics.push({ code: "WRN-TEMPLATE-LOOP-DIRECTIVE", severity: "error", message: `@${attribute.name} is an event binding, not a loop directive.`, hint: attribute.name === "for" ? 'Use data-for="item in items".' : 'Use data-key="item.id" alongside data-for.', file: options.file, }); } 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 ?? ""}:${diagnostic.position.line}:${diagnostic.position.column}` : (diagnostic.file ?? ""); 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"); }