feat(cli): report legacy api bodies for manual migration
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import { formatWrn, parse } from "@wrnexus/syntax";
|
||||
import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts";
|
||||
import { inspectProject, type DoctorCheck } from "./doctor.ts";
|
||||
import { migrateApisBlock } from "./migrations/apis-block.ts";
|
||||
import { detectLegacyApiBodies } from "./migrations/legacy-api-body.ts";
|
||||
import { migrateModeFunctions } from "./migrations/mode-functions.ts";
|
||||
import { generateApplicationTypes } from "./types.ts";
|
||||
|
||||
@@ -942,6 +943,51 @@ const MIGRATIONS: Migration[] = [
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "0.9.0",
|
||||
id: "report-legacy-api-bodies",
|
||||
description:
|
||||
"Reports legacy bare-body api entries for manual review. This is deliberately report-only: " +
|
||||
"payload fields cannot be told apart from page helpers without knowing the route's response " +
|
||||
"shape, so a guessed rewrite could compile while being silently wrong.",
|
||||
apply(ctx) {
|
||||
const appDirectory = join(ctx.appRoot, "app");
|
||||
if (!existsSync(appDirectory)) return;
|
||||
|
||||
let foundAny = false;
|
||||
for (const file of walkProjectFiles(appDirectory, ".wrn")) {
|
||||
const relativeFile = relative(ctx.appRoot, file).replace(/\\/g, "/");
|
||||
const source = readFileSync(file, "utf8");
|
||||
|
||||
let bodies: ReturnType<typeof detectLegacyApiBodies>;
|
||||
try {
|
||||
bodies = detectLegacyApiBodies(source);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message.split("\n", 1)[0] : String(error);
|
||||
ctx.report.parseFailures.push(`${relativeFile}: ${message}`);
|
||||
continue;
|
||||
}
|
||||
if (!bodies.length) continue;
|
||||
|
||||
foundAny = true;
|
||||
for (const block of bodies) {
|
||||
ctx.report.needsReview.push(
|
||||
`${relativeFile}: api '${block.name}' has a legacy bare body referencing ` +
|
||||
`${block.freeIdentifiers.join(", ")} — rewrite it by hand into request/response/error ` +
|
||||
`sections (nothing in the source tells payload fields apart from page helpers)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (foundAny) {
|
||||
ctx.log(
|
||||
"! legacy bare-body api entries need manual review: payload fields can't be told apart " +
|
||||
"from page helpers without the route's response shape, so this migration reports them " +
|
||||
"instead of guessing",
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Release tooling uses this to require an explicit migration entry per version. */
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { detectLegacyApiBodies } from "../src/migrations/legacy-api-body.ts";
|
||||
import { updateApp } from "../src/update.ts";
|
||||
|
||||
const SOURCE = `page Hello {
|
||||
ssr {
|
||||
api ssrUsers GET /api/users/ssr {
|
||||
return userNames(users)
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
test("a legacy bare body is detected with its free identifiers", () => {
|
||||
const found = detectLegacyApiBodies(SOURCE);
|
||||
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]!.name).toBe("ssrUsers");
|
||||
expect(found[0]!.freeIdentifiers).toContain("users");
|
||||
expect(found[0]!.freeIdentifiers).toContain("userNames");
|
||||
});
|
||||
|
||||
test("a sectioned block is not reported", () => {
|
||||
const sectioned = `page P {
|
||||
apis { x GET /api/x { response { return data.users } } }
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
expect(detectLegacyApiBodies(sectioned)).toEqual([]);
|
||||
});
|
||||
|
||||
test("an identifier that only appears inside a string literal is not collected", () => {
|
||||
const source = `page Hello {
|
||||
ssr {
|
||||
api ssrUsers GET /api/users/ssr {
|
||||
return "users are great, ask userNames"
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
const found = detectLegacyApiBodies(source);
|
||||
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]!.freeIdentifiers).not.toContain("users");
|
||||
expect(found[0]!.freeIdentifiers).not.toContain("userNames");
|
||||
});
|
||||
|
||||
test("a property-access key is not collected, only its object", () => {
|
||||
const source = `page Hello {
|
||||
ssr {
|
||||
api ssrUsers GET /api/users/ssr {
|
||||
return u.name
|
||||
}
|
||||
}
|
||||
|
||||
view { <main>x</main> }
|
||||
}
|
||||
`;
|
||||
|
||||
const found = detectLegacyApiBodies(source);
|
||||
|
||||
expect(found).toHaveLength(1);
|
||||
expect(found[0]!.freeIdentifiers).toContain("u");
|
||||
expect(found[0]!.freeIdentifiers).not.toContain("name");
|
||||
});
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function project(): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "wrnexus-migrate-legacy-api-body-"));
|
||||
roots.push(root);
|
||||
mkdirSync(join(root, "app"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "legacy-api-body-migrate-app",
|
||||
dependencies: { "@wrnexus/core": "^0.8.0" },
|
||||
wrnexus: { version: "0.8.0" },
|
||||
}),
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
test("a full update run leaves a legacy bare body file byte-identical and reports it", () => {
|
||||
const root = project();
|
||||
writeFileSync(join(root, "app", "Hello.wrn"), SOURCE);
|
||||
|
||||
const report = {
|
||||
changedAutomatically: [] as string[],
|
||||
needsReview: [] as string[],
|
||||
unresolvedImports: [] as string[],
|
||||
ambiguousFunctions: [] as string[],
|
||||
legacyOutputPayloads: [] as string[],
|
||||
parseFailures: [] as string[],
|
||||
};
|
||||
|
||||
updateApp(root, "0.9.0", false, { report });
|
||||
|
||||
const after = readFileSync(join(root, "app", "Hello.wrn"), "utf8");
|
||||
|
||||
expect(after).toBe(SOURCE);
|
||||
expect(report.needsReview.some((entry) => entry.includes("ssrUsers"))).toBe(true);
|
||||
});
|
||||
Reference in New Issue
Block a user