feat(cli): report legacy api bodies for manual migration

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 12:03:17 +05:30
co-authored by Claude Opus 5
parent e616ed276e
commit 4aa0973352
3 changed files with 494 additions and 0 deletions
@@ -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;
}
+46
View File
@@ -35,6 +35,7 @@ import { formatWrn, parse } from "@wrnexus/syntax";
import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts"; import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts";
import { inspectProject, type DoctorCheck } from "./doctor.ts"; import { inspectProject, type DoctorCheck } from "./doctor.ts";
import { migrateApisBlock } from "./migrations/apis-block.ts"; import { migrateApisBlock } from "./migrations/apis-block.ts";
import { detectLegacyApiBodies } from "./migrations/legacy-api-body.ts";
import { migrateModeFunctions } from "./migrations/mode-functions.ts"; import { migrateModeFunctions } from "./migrations/mode-functions.ts";
import { generateApplicationTypes } from "./types.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. */ /** 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);
});