feat(cli): migrate mode-scoped helpers to shared functions

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 11:58:16 +05:30
co-authored by Claude Opus 5
parent 74490964ee
commit e616ed276e
3 changed files with 444 additions and 0 deletions
@@ -0,0 +1,274 @@
/**
* Migration: move mode-scoped helpers out of legacy `ssr { functions { … } }`
* / `client { functions { … } }` blocks into the page-level `functions { }`
* block, tagged `shared`.
*
* IMPORTANT: this is a text-level transform, not a parse-and-rewrite, for the
* same reason as `apis-block.ts`: the `@wrnexus/syntax` parser deliberately
* THROWS a `ParseError` on `ssr { … }` / `client { … }` data blocks — that
* legacy syntax is exactly what this migration exists to read, so it cannot
* be reached by parsing first. Instead this scans the source with the same
* balanced-brace technique used throughout `update.ts` (`findMatching`), and
* only parses the RESULT, as a validity check.
*/
import { parse } from "@wrnexus/syntax";
/** 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 of the `ssr`/`client` keyword. */
start: number;
/** One past the block's closing `}`. */
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 FunctionsSubBlock {
/** Offsets relative to the mode block's body. */
localStart: number;
localEnd: number;
bodyStart: number;
bodyEnd: number;
}
/** Find `functions { … }` sub-blocks inside a mode-block body. */
function findFunctionsSubBlocks(body: string): FunctionsSubBlock[] {
const blocks: FunctionsSubBlock[] = [];
const header = /\bfunctions\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 "functions {" block at offset ${match.index}`);
}
blocks.push({
localStart: match.index,
localEnd: close + 1,
bodyStart: open + 1,
bodyEnd: close,
});
header.lastIndex = close + 1;
}
return blocks;
}
interface FunctionEntry {
name: string;
/** `function name(...) { ... }` text, with any leading runtime keyword stripped. */
text: string;
}
/** Find `[client|server|shared] function <name>(...) { ... }` entries inside a functions-block body. */
function findFunctionEntries(body: string): FunctionEntry[] {
const entries: FunctionEntry[] = [];
const header = /\b(?:(?:client|server|shared)\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/g;
let match: RegExpExecArray | null;
while ((match = header.exec(body))) {
const parenOpen = match.index + match[0].length - 1;
const parenClose = findMatching(body, parenOpen, "(", ")");
if (parenClose < 0) {
throw new Error(`unterminated function parameter list at offset ${match.index}`);
}
const braceOpen = body.indexOf("{", parenClose + 1);
if (braceOpen < 0) {
throw new Error(`unterminated function body at offset ${match.index}`);
}
const braceClose = findMatching(body, braceOpen);
if (braceClose < 0) {
throw new Error(`unterminated function body at offset ${match.index}`);
}
const localStart = match.index;
const localEnd = braceClose + 1;
const text = body.slice(localStart, localEnd).replace(/^(client|server|shared)\s+/, "");
entries.push({ name: match[1]!, text });
header.lastIndex = localEnd;
}
return entries;
}
interface Edit {
start: number;
end: number;
replacement: string;
}
function applyEdits(source: string, edits: Edit[]): string {
const sorted = [...edits].sort((a, b) => a.start - b.start);
let output = "";
let cursor = 0;
for (const edit of sorted) {
output += source.slice(cursor, edit.start) + edit.replacement;
cursor = edit.end;
}
output += source.slice(cursor);
return output;
}
export function migrateModeFunctions(
source: string,
): { source: string; changed: boolean } | { skip: string } {
const blocks = findModeBlocks(source);
if (blocks.length === 0) return { source, changed: false };
const allEntries: FunctionEntry[] = [];
const nameMode = new Map<string, "ssr" | "client">();
const perBlockFuncBlocks = new Map<ModeBlock, FunctionsSubBlock[]>();
for (const block of blocks) {
const body = source.slice(block.bodyStart, block.bodyEnd);
const funcBlocks = findFunctionsSubBlocks(body);
if (funcBlocks.length === 0) continue;
for (const funcBlock of funcBlocks) {
const funcBody = body.slice(funcBlock.bodyStart, funcBlock.bodyEnd);
for (const entry of findFunctionEntries(funcBody)) {
const existingMode = nameMode.get(entry.name);
if (existingMode && existingMode !== block.mode) {
return {
skip: `function '${entry.name}' is declared in both ssr and client — merge them by hand before this migration can run`,
};
}
nameMode.set(entry.name, block.mode);
allEntries.push(entry);
}
}
perBlockFuncBlocks.set(block, funcBlocks);
}
if (allEntries.length === 0) return { source, changed: false };
// Locate an existing page-level `functions { }` block, i.e. one that is not
// inside any ssr/client mode block.
const insideAMode = (index: number) =>
blocks.some((block) => index >= block.start && index < block.end);
let existingFunctions: { bodyStart: number; bodyEnd: number } | null = null;
{
const header = /\bfunctions\s*\{/g;
let match: RegExpExecArray | null;
while ((match = header.exec(source))) {
if (insideAMode(match.index)) continue;
const open = match.index + match[0].length - 1;
const close = findMatching(source, open);
if (close < 0) throw new Error(`unterminated "functions {" block at offset ${match.index}`);
existingFunctions = { bodyStart: open + 1, bodyEnd: close };
break;
}
}
if (existingFunctions) {
const existingBody = source.slice(existingFunctions.bodyStart, existingFunctions.bodyEnd);
for (const entry of allEntries) {
if (new RegExp(`\\bfunction\\s+${entry.name}\\b`).test(existingBody)) {
return {
skip: `'${entry.name}' already exists in the page-level functions block`,
};
}
}
}
const designatedBlock = blocks.find((block) => perBlockFuncBlocks.has(block));
const edits: Edit[] = [];
// True when a mode block keeps non-function content (e.g. `api` entries)
// after its functions are extracted. The grammar rejects any `ssr {}` /
// `client {}` wrapper outright, regardless of what's inside, so such a
// leftover wrapper cannot parse until move-api-blocks removes it in the
// same `wrnexus update` run. Validating with `parse` here would therefore
// always fail on a case this migration is specifically meant to unblock.
let leavesModeWrapper = false;
for (const block of blocks) {
const funcBlocks = perBlockFuncBlocks.get(block);
if (!funcBlocks) continue; // block had no functions sub-block; leave it untouched entirely
const body = source.slice(block.bodyStart, block.bodyEnd);
let remainder = body;
for (const funcBlock of [...funcBlocks].reverse()) {
remainder = remainder.slice(0, funcBlock.localStart) + remainder.slice(funcBlock.localEnd);
}
const isEmpty = remainder.trim() === "";
if (!isEmpty) leavesModeWrapper = true;
if (block === designatedBlock && !existingFunctions) {
const funcsBody = allEntries.map((entry) => ` shared ${entry.text}`).join("\n\n");
const funcsBlockText = `functions {\n${funcsBody}\n }`;
const replacement = isEmpty
? funcsBlockText
: `${funcsBlockText}\n\n ${block.mode} {${remainder}}`;
edits.push({ start: block.start, end: block.end, replacement });
} else {
const replacement = isEmpty ? "" : `${block.mode} {${remainder}}`;
edits.push({ start: block.start, end: block.end, replacement });
}
}
if (existingFunctions) {
const existingBody = source.slice(existingFunctions.bodyStart, existingFunctions.bodyEnd);
const additions = allEntries.map((entry) => ` shared ${entry.text}`).join("\n\n");
const mergedBody = `${existingBody.replace(/\s*$/, "")}\n\n${additions}\n `;
edits.push({
start: existingFunctions.bodyStart,
end: existingFunctions.bodyEnd,
replacement: mergedBody,
});
}
let after = applyEdits(source, edits);
after = after.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n");
if (after === source) return { source, changed: false };
if (!leavesModeWrapper) {
try {
parse(after);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`mode-functions migration produced source that failed to parse: ${message}`);
}
}
return { source: after, changed: true };
}
+38
View File
@@ -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 { migrateModeFunctions } from "./migrations/mode-functions.ts";
import { generateApplicationTypes } from "./types.ts";
/** The version of the CLI currently running (its own package.json). */
@@ -869,6 +870,43 @@ const MIGRATIONS: Migration[] = [
if (!ctx.dryRun) writeFileSync(file, after, "utf8");
},
},
{
version: "0.9.0",
id: "move-mode-functions",
description:
"Move mode-scoped helpers out of legacy ssr/client functions blocks into a page-level shared functions block",
apply(ctx) {
const appDirectory = join(ctx.appRoot, "app");
if (!existsSync(appDirectory)) return;
for (const file of walkProjectFiles(appDirectory, ".wrn")) {
const relativeFile = relative(ctx.appRoot, file).replace(/\\/g, "/");
const before = readFileSync(file, "utf8");
let result: ReturnType<typeof migrateModeFunctions>;
try {
result = migrateModeFunctions(before);
} catch (error) {
const message = error instanceof Error ? error.message.split("\n", 1)[0] : String(error);
ctx.report.parseFailures.push(`${relativeFile}: ${message}`);
ctx.log(`! ${relativeFile}: left unchanged because the mode-functions migration failed`);
continue;
}
if ("skip" in result) {
ctx.report.needsReview.push(`${relativeFile}: ${result.skip}`);
continue;
}
if (!result.changed) continue;
ctx.report.changedAutomatically.push(
`${relativeFile}: moved mode-scoped helpers into functions { shared … }`,
);
ctx.log(`~ ${relativeFile}: moved mode-scoped helpers into functions { shared … }`);
if (!ctx.dryRun) writeFileSync(file, result.source, "utf8");
}
},
},
{
version: "0.9.0",
id: "move-api-blocks",
@@ -0,0 +1,132 @@
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 { migrateModeFunctions } from "../src/migrations/mode-functions.ts";
import { updateApp } from "../src/update.ts";
const SOURCE = `page Hello {
ssr {
functions {
function userNames(users) {
return users.map((user) => user.name).join(", ")
}
}
}
view { <main>x</main> }
}
`;
test("a mode helper becomes a shared function", () => {
const result = migrateModeFunctions(SOURCE) as { source: string; changed: boolean };
expect(result.changed).toBe(true);
expect(result.source).toContain("shared function userNames");
expect(result.source).not.toContain("ssr {");
});
test("running it again changes nothing", () => {
const once = (migrateModeFunctions(SOURCE) as { source: string }).source;
const twice = migrateModeFunctions(once) as { changed: boolean; source: string };
expect(twice.changed).toBe(false);
expect(twice.source).toBe(once);
});
test("a name that already exists at page level is skipped with a reason", () => {
const clash = `page P {
functions { shared function userNames() { return "" } }
ssr { functions { function userNames(users) { return "" } } }
view { <main>x</main> }
}
`;
const result = migrateModeFunctions(clash) as { skip: string };
expect(result.skip).toContain("userNames");
});
test("a helper body containing a brace inside a string literal survives", () => {
const source = `page Hello {
ssr {
functions {
function label(user) {
return user.name + " {tag}"
}
}
}
view { <main>x</main> }
}
`;
const result = migrateModeFunctions(source) as { source: string; changed: boolean };
expect(result.changed).toBe(true);
expect(result.source).toContain("shared function label");
expect(result.source).toContain('" {tag}"');
});
test("both ssr and client declaring the same helper name is skipped with a reason", () => {
const clash = `page P {
ssr { functions { function helper() { return 1 } } }
client { functions { function helper() { return 2 } } }
view { <main>x</main> }
}
`;
const result = migrateModeFunctions(clash) as { skip: string };
expect(result.skip).toContain("helper");
});
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-mode-functions-"));
roots.push(root);
mkdirSync(join(root, "app"), { recursive: true });
writeFileSync(
join(root, "package.json"),
JSON.stringify({
name: "mode-functions-migrate-app",
dependencies: { "@wrnexus/core": "^0.8.0" },
wrnexus: { version: "0.8.0" },
}),
);
return root;
}
test("one update run fully migrates a page mixing api entries and mode functions", () => {
const root = project();
writeFileSync(
join(root, "app", "P.wrn"),
`page P {
ssr {
api getUsers GET /api/users { response { return data } }
functions { function label(u) { return u.name } }
}
view { <main>x</main> }
}
`,
);
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 migrated = readFileSync(join(root, "app", "P.wrn"), "utf8");
expect(migrated).toContain("apis {");
expect(migrated).toContain("shared function label");
expect(migrated).not.toContain("ssr {");
expect(report.needsReview).toEqual([]);
});