Files
WRNexusJS/packages/cli/src/migrations/legacy-api-body.ts
T

334 lines
9.0 KiB
TypeScript

/**
* 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 <name> <METHOD> <path> { … }` 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<string> {
const names = new Set<string>();
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<string>();
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;
}