/** * Detection (not migration) for legacy bare-body `api` entries. * * A legacy bare `api` body — one with no `request { }` / `response { }` / * `error { }` sections — is evaluated inside `with ($data ?? {})`, so it * references payload fields as bare identifiers. Converting * `return userNames(users)` correctly needs `data.users`, but nothing in the * source distinguishes `users` (a payload field) from `userNames` (a page * helper) — the response shape belongs to the route, which may not be typed. * A migration that guessed would emit code that compiles and is silently * wrong, so this module deliberately reports free identifiers rather than * rewriting anything. * * Like `apis-block.ts` / `mode-functions.ts`, this is a text-level scan, not * a parse-and-rewrite: the `@wrnexus/syntax` parser THROWS on `ssr { … }` / * `client { … }` data blocks, which is exactly the legacy syntax this module * reads. It uses the same balanced-brace technique (`findMatching`) used * throughout `update.ts`. */ /** Balanced-brace scan: returns the index of the brace matching `open`, or -1. */ function findMatching(source: string, open: number, openChar = "{", closeChar = "}"): number { let depth = 0; let quote = ""; for (let index = open; index < source.length; index++) { const char = source[index]!; if (quote) { if (char === "\\") index++; else if (char === quote) quote = ""; continue; } if (char === '"' || char === "'" || char === "`") quote = char; else if (char === openChar) depth++; else if (char === closeChar && --depth === 0) return index; } return -1; } interface ModeBlock { mode: "ssr" | "client"; start: number; end: number; bodyStart: number; bodyEnd: number; } /** Find page-level `ssr { … }` / `client { … }` data blocks (not `state`/hydrate forms). */ function findModeBlocks(source: string): ModeBlock[] { const blocks: ModeBlock[] = []; const header = /\b(ssr|client)\s*\{/g; let match: RegExpExecArray | null; while ((match = header.exec(source))) { const open = match.index + match[0].length - 1; const close = findMatching(source, open); if (close < 0) { throw new Error(`unterminated "${match[1]} {" block at offset ${match.index}`); } blocks.push({ mode: match[1] as "ssr" | "client", start: match.index, end: close + 1, bodyStart: open + 1, bodyEnd: close, }); header.lastIndex = close + 1; } return blocks; } interface LegacyApiEntry { name: string; /** True when the entry declares request/response/error sections. */ hasSections: boolean; /** The entry's body text (between its outer braces). */ bodyText: string; } /** Find `api { … }` entries inside a mode-block body. */ function findApiEntries(body: string): LegacyApiEntry[] { const entries: LegacyApiEntry[] = []; const header = /\bapi\s+([A-Za-z_$][\w$]*)\s+([A-Za-z]+)\s+(\S+?)\s*\{/g; let match: RegExpExecArray | null; while ((match = header.exec(body))) { const open = match.index + match[0].length - 1; const close = findMatching(body, open); if (close < 0) { throw new Error(`unterminated api entry '${match[1]}' at offset ${match.index}`); } const bodyText = body.slice(open + 1, close); const maskedBodyText = maskLiteralsAndComments(bodyText); const hasSections = /\b(request|response|error)\s*\{/.test(maskedBodyText); entries.push({ name: match[1]!, hasSections, bodyText }); header.lastIndex = close + 1; } return entries; } /** Blank out string/template literal contents and comments, preserving length. */ function maskLiteralsAndComments(source: string): string { let out = ""; let index = 0; while (index < source.length) { const char = source[index]!; if (char === '"' || char === "'" || char === "`") { const quote = char; let end = index + 1; while (end < source.length) { if (source[end] === "\\") { end += 2; continue; } if (source[end] === quote) { end++; break; } end++; } out += " ".repeat(end - index); index = end; continue; } if (char === "/" && source[index + 1] === "/") { let end = index; while (end < source.length && source[end] !== "\n") end++; out += " ".repeat(end - index); index = end; continue; } if (char === "/" && source[index + 1] === "*") { let end = source.indexOf("*/", index + 2); end = end < 0 ? source.length : end + 2; out += source.slice(index, end).replace(/[^\n]/g, " "); index = end; continue; } out += char; index++; } return out; } const JS_KEYWORDS = new Set([ "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", "do", "else", "enum", "export", "extends", "false", "finally", "for", "function", "if", "implements", "import", "in", "instanceof", "interface", "let", "new", "null", "of", "package", "private", "protected", "public", "return", "static", "super", "switch", "this", "throw", "true", "try", "typeof", "undefined", "var", "void", "while", "with", "yield", "async", "await", "get", "set", ]); const JS_GLOBALS = new Set([ "Math", "JSON", "Object", "Array", "String", "Number", "Boolean", "Date", "RegExp", "Map", "Set", "Promise", "Error", "TypeError", "RangeError", "SyntaxError", "EvalError", "ReferenceError", "URIError", "console", "NaN", "Infinity", "globalThis", "Symbol", "WeakMap", "WeakSet", "Proxy", "Reflect", "parseInt", "parseFloat", "isNaN", "isFinite", "encodeURIComponent", "decodeURIComponent", "encodeURI", "decodeURI", "structuredClone", "BigInt", "ArrayBuffer", "Int8Array", "Uint8Array", "Int16Array", "Uint16Array", "Int32Array", "Uint32Array", "Float32Array", "Float64Array", "DataView", ]); /** Identifiers that name a param, are function names, or are declared via const/let/var/catch. */ function collectDeclaredNames(masked: string): Set { const names = new Set(); for (const m of masked.matchAll(/\bfunction\s*(?:\*\s*)?([A-Za-z_$][\w$]*)?\s*\(([^)]*)\)/g)) { if (m[1]) names.add(m[1]); for (const name of extractIdentifiers(m[2] ?? "")) names.add(name); } for (const m of masked.matchAll(/\(([^)]*)\)\s*=>/g)) { for (const name of extractIdentifiers(m[1] ?? "")) names.add(name); } for (const m of masked.matchAll(/(?:^|[^\w$.])([A-Za-z_$][\w$]*)\s*=>/g)) { names.add(m[1]!); } for (const m of masked.matchAll(/\b(?:const|let|var)\s+([^;\n]+)/g)) { for (const part of m[1]!.split(",")) { const declarator = part.split("=")[0]!; for (const name of extractIdentifiers(declarator)) names.add(name); } } for (const m of masked.matchAll(/\bcatch\s*\(\s*([A-Za-z_$][\w$]*)\s*\)/g)) { names.add(m[1]!); } return names; } function extractIdentifiers(text: string): string[] { return Array.from(text.matchAll(/[A-Za-z_$][\w$]*/g), (m) => m[0]); } /** * Identifiers a bare `api` body references that are neither declared locally * (params, `const`/`let`/`var`, function names, catch bindings) nor * JavaScript globals. Property-access keys (`u.name` → `u`, not `name`) and * object-literal keys are excluded; string/template contents never reach the * scan at all. */ function freeIdentifiersOf(body: string): string[] { const masked = maskLiteralsAndComments(body); const declared = collectDeclaredNames(masked); const free = new Set(); const identifier = /[A-Za-z_$][\w$]*/g; let match: RegExpExecArray | null; while ((match = identifier.exec(masked))) { const name = match[0]; if (JS_KEYWORDS.has(name)) continue; let before = match.index - 1; while (before >= 0 && /\s/.test(masked[before]!)) before--; if (before >= 0 && masked[before] === ".") continue; // property access let after = match.index + name.length; while (after < masked.length && /\s/.test(masked[after]!)) after++; if (masked[after] === ":" && masked[after + 1] !== ":") { if (before >= 0 && (masked[before] === "{" || masked[before] === ",")) continue; // object key } if (declared.has(name)) continue; if (JS_GLOBALS.has(name)) continue; free.add(name); } return [...free]; } /** * Find legacy bare-body `api` entries — those with no `request`/`response`/ * `error` sections — and report each one's name and free identifiers. * Writes nothing; the caller decides what to do with the report. */ export function detectLegacyApiBodies( source: string, ): { name: string; freeIdentifiers: string[] }[] { const results: { name: string; freeIdentifiers: string[] }[] = []; for (const block of findModeBlocks(source)) { const body = source.slice(block.bodyStart, block.bodyEnd); for (const entry of findApiEntries(body)) { if (entry.hasSections) continue; results.push({ name: entry.name, freeIdentifiers: freeIdentifiersOf(entry.bodyText) }); } } return results; }