334 lines
9.0 KiB
TypeScript
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;
|
|
}
|