From 4aa0973352abd9a2144430b3ac7c498dac34fb99 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Thu, 20 Aug 2026 12:03:17 +0530 Subject: [PATCH] feat(cli): report legacy api bodies for manual migration Co-Authored-By: Claude Opus 5 --- .../cli/src/migrations/legacy-api-body.ts | 333 ++++++++++++++++++ packages/cli/src/update.ts | 46 +++ .../cli/test/migrate-legacy-api-body.test.ts | 115 ++++++ 3 files changed, 494 insertions(+) create mode 100644 packages/cli/src/migrations/legacy-api-body.ts create mode 100644 packages/cli/test/migrate-legacy-api-body.test.ts diff --git a/packages/cli/src/migrations/legacy-api-body.ts b/packages/cli/src/migrations/legacy-api-body.ts new file mode 100644 index 00000000..7dfcc48b --- /dev/null +++ b/packages/cli/src/migrations/legacy-api-body.ts @@ -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 { … }` 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; +} diff --git a/packages/cli/src/update.ts b/packages/cli/src/update.ts index 57aa5b23..c6556769 100644 --- a/packages/cli/src/update.ts +++ b/packages/cli/src/update.ts @@ -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; + 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. */ diff --git a/packages/cli/test/migrate-legacy-api-body.test.ts b/packages/cli/test/migrate-legacy-api-body.test.ts new file mode 100644 index 00000000..c82acc16 --- /dev/null +++ b/packages/cli/test/migrate-legacy-api-body.test.ts @@ -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 {
x
} +} +`; + +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 {
x
} +} +`; + + 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 {
x
} +} +`; + + 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 {
x
} +} +`; + + 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); +});