diff --git a/docs/superpowers/plans/2026-08-19-apis-block.md b/docs/superpowers/plans/2026-08-19-apis-block.md new file mode 100644 index 00000000..b907edd4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-apis-block.md @@ -0,0 +1,1186 @@ +# The `apis { }` Block Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** One page-level `apis { }` container whose entries are callable from anywhere as `api.(input)` — dispatched in-process on the server and over `fetch` in the browser. + +**Architecture:** The parser gains a mode-less container. The compiler emits one `api` object per execution context, each closing over its own transport; the two never meet. Server-side calls get their request context from an `AsyncLocalStorage` established at the single shared request entry. The old `ssr {}` / `client {}` data blocks are removed only after the new path works and the example is migrated, so the repo always builds. + +**Tech Stack:** Bun, TypeScript, `bun:test`, happy-dom, `node:async_hooks`. + +**Spec:** `docs/superpowers/specs/2026-08-19-apis-block-design.md` + +## Global Constraints + +- Targets are this app's `/api/*` routes only. Never relax `isSafeApiPath` in `packages/dev-server/src/runtime.ts`. +- This plan adds no configuration key. +- Absent an `error {}` section, a failed call rejects. Nothing may resolve to `undefined` on failure. +- Declared field types are type-only. No TypeScript may reach the emitted browser module — it is written as `.mjs` and parsed as JavaScript. +- A block's `response` / `error` bodies ship to the browser **only** when client code calls that block. +- Request assembly rules are **shared** between the browser and server transports, never reimplemented. +- `REACTIVE_RUNTIME` in `packages/csr/src/reactive-runtime.ts` is a template literal — a backtick added inside it breaks the file. Use plain quotes. +- `bun run format` before every commit; the gate is `bun run check:production`. Rebuild editor bundles after `packages/syntax` or `packages/compiler` changes. +- Do NOT use `node -e`, shell heredocs, or `sed` to write code into files. + +--- + +### Task 1: Parse the `apis { }` container + +**Files:** + +- Modify: `packages/syntax/src/parser.ts` (add a `case "apis":` beside `case "functions":` at ~line 828; extend `DataApiBlock`) +- Test: `packages/syntax/test/apis-block.test.ts` + +**Interfaces:** + +- Consumes: `parseApiSections` from `packages/syntax/src/api-sections.ts` (already exists — it parses `request` / `response` / `error` and returns `null` for a bare body). +- Produces: entries land in `ast.dataApis` as `DataApiBlock` with `mode: "any"`. Later tasks filter on `block.mode === "any"` to find them. Existing `"ssr"` / `"client"` entries are untouched by this task. + +An `apis { }` entry is ` { … }` — the same shape as today's `api` entry minus the `api` keyword, since the container supplies it. + +- [ ] **Step 1: Write the failing test** + +Create `packages/syntax/test/apis-block.test.ts`: + +```ts +import { expect, test } from "bun:test"; +import { parse } from "../src/index.ts"; + +const page = (inner: string) => `page Repro { + apis { +${inner} + } + + view {
x
} +} +`; + +test("parses a mode-less entry with its sections", () => { + const ast = parse( + page(` searchUsers POST /api/users { + request { + body { + name?: string + } + } + + response { return data.users } + error { return [] } + }`), + ); + + const block = ast.dataApis[0]!; + expect(block.name).toBe("searchUsers"); + expect(block.method).toBe("POST"); + expect(block.path).toBe("/api/users"); + expect(block.mode).toBe("any"); + expect(block.sections?.body).toEqual([{ name: "name", optional: true, type: "string" }]); + expect(block.sections?.response.trim()).toBe("return data.users"); + expect(block.sections?.error.trim()).toBe("return []"); +}); + +test("parses several entries in one container", () => { + const ast = parse( + page(` a GET /api/a { response { return data } } + b POST /api/b { response { return data } }`), + ); + + expect(ast.dataApis.map((block) => block.name)).toEqual(["a", "b"]); +}); + +test("a GET entry declares parameters", () => { + const ast = parse( + page(` listTeams GET /api/teams { + request { + parameters { + team: string + } + } + + response { return data.teams } + }`), + ); + + expect(ast.dataApis[0]!.sections?.parameters).toEqual([ + { name: "team", optional: false, type: "string" }, + ]); +}); + +test("duplicate names inside one container are rejected", () => { + expect(() => + parse( + page(` dup GET /api/a { response { return data } } + dup POST /api/b { response { return data } }`), + ), + ).toThrow(/duplicate/i); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test packages/syntax/test/apis-block.test.ts` +Expected: FAIL — `apis` is not a known page member. + +- [ ] **Step 3: Widen `DataMode` and the block type** + +In `packages/syntax/src/parser.ts`, extend the mode union used by `DataApiBlock` so a mode-less entry is representable: + +```ts +export type DataMode = "ssr" | "client" | "any"; +``` + +Leave `DataApiBlock`'s other members as they are. + +- [ ] **Step 4: Parse the container** + +Add a `case "apis":` to the page-level member switch, beside `case "functions":`: + +```ts + case "apis": { + lx.next(); + const body = lx.readBalancedBraces(); + for (const entry of parseApiEntries(body)) { + if (dataApis.some((block) => block.name === entry.name)) { + throw new ParseError(`Duplicate api entry "${entry.name}" in apis block`); + } + dataApis.push(entry); + } + break; + } +``` + +Then add `parseApiEntries(source: string): DataApiBlock[]` to `packages/syntax/src/api-sections.ts`. It scans the container body at depth zero for ` {` and slices each entry's braces with the tokenizer's `Lexer.readBalancedBraces()` — **reuse that**, do not hand-roll a brace counter; the existing scanner is string- and comment-aware for a reason. Each entry becomes: + +```ts +{ mode: "any", name, method: method.toUpperCase(), path, body: "", sections: parseApiSections(entryBody) ?? emptySections } +``` + +where `emptySections` is `{ parameters: [], body: [], response: "", error: "" }`. + +- [ ] **Step 5: Run the tests** + +Run: `bun test packages/syntax` +Expected: PASS, including the pre-existing suite — nothing about the old forms changed. + +- [ ] **Step 6: Commit** + +```bash +bun run format +bun run --cwd editors/vscode build +git add packages/syntax editors/vscode/src +git commit -m "feat(syntax): parse the apis container block" +``` + +--- + +### Task 2: Request context via AsyncLocalStorage + +**Files:** + +- Create: `packages/core/src/request-context.ts` +- Modify: `packages/core/src/index.ts` (export it) +- Modify: `packages/dev-server/src/runtime.ts` (`fetchHandler` at ~line 1041) +- Test: `packages/core/test/request-context.test.ts` + +**Interfaces:** + +- Produces: + - `runWithRequestContext(ctx: Context, fn: () => T): T` + - `getRequestContext(): Context | undefined` + - `requireRequestContext(what: string): Context` — throws a message naming `what` when absent. + + Task 5 calls `requireRequestContext`. + +`fetchHandler` is the single request entry shared by the dev server and `createProductionServer`, so establishing the context there covers both. Doing it in only one would make a call that works in development fail in production. + +- [ ] **Step 1: Write the failing test** + +Create `packages/core/test/request-context.test.ts`: + +```ts +import { expect, test } from "bun:test"; +import { + getRequestContext, + requireRequestContext, + runWithRequestContext, +} from "../src/request-context.ts"; + +const ctx = { marker: "the-request" } as never; + +test("the context is visible inside the run", () => { + runWithRequestContext(ctx, () => { + expect(getRequestContext()).toBe(ctx); + }); +}); + +test("the context is visible across an await", async () => { + // The whole point is that it survives async boundaries a caller cannot see. + await runWithRequestContext(ctx, async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + expect(getRequestContext()).toBe(ctx); + }); +}); + +test("there is no context outside a run", () => { + expect(getRequestContext()).toBeUndefined(); +}); + +test("requireRequestContext throws a message naming the caller", () => { + expect(() => requireRequestContext("api.searchUsers")).toThrow(/api\.searchUsers/); +}); + +test("concurrent runs do not see each other's context", async () => { + const first = { id: 1 } as never; + const second = { id: 2 } as never; + const seen: unknown[] = []; + + await Promise.all([ + runWithRequestContext(first, async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + seen.push(getRequestContext()); + }), + runWithRequestContext(second, async () => { + seen.push(getRequestContext()); + }), + ]); + + expect(seen).toContain(first); + expect(seen).toContain(second); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test packages/core/test/request-context.test.ts` +Expected: FAIL — the module does not exist. + +- [ ] **Step 3: Implement the store** + +Create `packages/core/src/request-context.ts`: + +```ts +import { AsyncLocalStorage } from "node:async_hooks"; +import type { Context } from "./context.ts"; + +/** + * The request context for the currently executing server work. + * + * A server-side API call needs the request's cookies, session, and URL, but + * `ctx` is not in scope everywhere server code runs: load blocks have it, + * schema actions take it as a parameter, and plain actions and server + * functions have neither. Threading it through every signature would make the + * call site differ between server and browser, which defeats the point. + */ +const storage = new AsyncLocalStorage(); + +export function runWithRequestContext(ctx: Context, fn: () => T): T { + return storage.run(ctx, fn); +} + +export function getRequestContext(): Context | undefined { + return storage.getStore(); +} + +export function requireRequestContext(what: string): Context { + const ctx = storage.getStore(); + + if (!ctx) { + throw new Error( + `${what} needs a request context. It ran outside a request — server-side API calls are only available while handling one.`, + ); + } + + return ctx; +} +``` + +Export all three from `packages/core/src/index.ts`. + +- [ ] **Step 4: Establish it at the request entry** + +In `packages/dev-server/src/runtime.ts`, wrap the body of `fetchHandler` (~line 1041) so everything it does runs inside the store. The context object is created inside that function; wrap from the point it exists: + +```ts +return runWithRequestContext(ctx, async () => { + // ...the existing body, unchanged... +}); +``` + +Import `runWithRequestContext` from `@wrnexus/core`. + +- [ ] **Step 5: Run the tests** + +Run: `bun test packages/core packages/dev-server` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +bun run format +git add packages/core packages/dev-server +git commit -m "feat(core): carry the request context in an AsyncLocalStorage" +``` + +--- + +### Task 3: Shared request assembly, and a server transport that carries input + +**Files:** + +- Create: `packages/core/src/api-request.ts` +- Modify: `packages/core/src/index.ts` +- Modify: `packages/dev-server/src/runtime.ts` (`callApiFromContext`, ~line 1447) +- Modify: `packages/csr/src/reactive-runtime.ts` (`wrnexusCallApi` uses the same rules) +- Test: `packages/core/test/api-request.test.ts` + +**Interfaces:** + +- Produces: `buildApiRequest(path: string, method: string, input: Record | undefined): { url: string; body?: string; contentType?: string }`. + - `GET` / `HEAD`: fields become a query string; `undefined`, `null`, and `""` are omitted. `0` and `false` are **kept** — they are legitimate values. + - Everything else: `body` is `JSON.stringify(input ?? {})` with `contentType: "application/json"`. + + Tasks 4 and 5 both use this. A second copy would drift, and the drift would be silent because each side is tested separately. + +`callApiFromContext` currently builds `new Request(apiUrl, { method, headers })` — no body, no query. A server-side `api.searchUsers({ name })` would send nothing. + +- [ ] **Step 1: Write the failing test** + +Create `packages/core/test/api-request.test.ts`: + +```ts +import { expect, test } from "bun:test"; +import { buildApiRequest } from "../src/api-request.ts"; + +test("GET builds a query string", () => { + expect(buildApiRequest("/api/users", "GET", { name: "Ajay" }).url).toBe("/api/users?name=Ajay"); +}); + +test("GET omits undefined, null and empty string", () => { + const built = buildApiRequest("/api/users", "GET", { + name: "Ajay", + age: undefined, + team: null, + note: "", + }); + + expect(built.url).toBe("/api/users?name=Ajay"); +}); + +test("GET keeps 0 and false", () => { + // A filter of 0 or false is a real value; dropping it silently would be a bug. + const built = buildApiRequest("/api/users", "GET", { count: 0, active: false }); + + expect(built.url).toContain("count=0"); + expect(built.url).toContain("active=false"); +}); + +test("GET has no body", () => { + expect(buildApiRequest("/api/users", "GET", { name: "Ajay" }).body).toBeUndefined(); +}); + +test("POST sends a JSON body and no query", () => { + const built = buildApiRequest("/api/users", "POST", { name: "Ajay" }); + + expect(built.url).toBe("/api/users"); + expect(built.body).toBe(JSON.stringify({ name: "Ajay" })); + expect(built.contentType).toBe("application/json"); +}); + +test("POST with no input sends an empty object", () => { + expect(buildApiRequest("/api/users", "POST", undefined).body).toBe("{}"); +}); + +test("values are encoded", () => { + expect(buildApiRequest("/api/users", "GET", { name: "a b&c" }).url).toBe( + "/api/users?name=a%20b%26c", + ); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test packages/core/test/api-request.test.ts` +Expected: FAIL — the module does not exist. + +- [ ] **Step 3: Implement the shared builder** + +Create `packages/core/src/api-request.ts`: + +```ts +/** + * Assemble an API request from a block's declared input. + * + * Shared by both transports on purpose. The browser and the in-process server + * caller must send the same thing for the same call; two copies of these rules + * would drift, and the drift would be invisible because each side is tested + * separately. + */ +export interface BuiltApiRequest { + url: string; + body?: string; + contentType?: string; +} + +export function buildApiRequest( + path: string, + method: string, + input: Record | undefined, +): BuiltApiRequest { + const verb = String(method || "GET").toUpperCase(); + const values = input ?? {}; + + if (verb === "GET" || verb === "HEAD") { + const query: string[] = []; + + for (const [key, value] of Object.entries(values)) { + // 0 and false are legitimate values and must survive. + if (value === undefined || value === null || value === "") continue; + query.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + } + + return { url: query.length ? `${path}?${query.join("&")}` : path }; + } + + return { url: path, body: JSON.stringify(values), contentType: "application/json" }; +} +``` + +Export it from `packages/core/src/index.ts`. + +- [ ] **Step 4: Teach `callApiFromContext` to carry input** + +In `packages/dev-server/src/runtime.ts`, change the signature and body: + +```ts + async function callApiFromContext( + ctx: Context, + path: string, + method = "GET", + input?: Record, + ): Promise { + if (!isSafeApiPath(path)) { + throw new Error("Unsafe framework API path"); + } + + const normalizedMethod = method.toUpperCase(); + if (!HTTP_METHODS.includes(normalizedMethod as (typeof HTTP_METHODS)[number])) { + throw new Error(`Unsupported framework API method: ${normalizedMethod}`); + } + + const built = buildApiRequest(path, normalizedMethod, input); + const apiUrl = new URL(built.url, ctx.req.url); + const headers = new Headers(ctx.req.headers); + if (built.contentType) headers.set("content-type", built.contentType); + + const apiReq = new Request(apiUrl, { + method: normalizedMethod, + headers, + ...(built.body === undefined ? {} : { body: built.body }), + }); + // ...the rest of the function is unchanged... +``` + +`isSafeApiPath` is applied to `path`, **before** the query string is added — it rejects `?`, and the query is legitimate here. + +- [ ] **Step 5: Point the browser transport at the same rules** + +In `packages/csr/src/reactive-runtime.ts`, `wrnexusCallApi` currently inlines the query and body logic. The runtime is a browser string and cannot import from `@wrnexus/core`, so **the emitted runtime must be generated from the shared rules rather than duplicating them by hand**: keep the runtime's implementation, and add a test asserting the two agree (Step 6). If they ever disagree, that test fails. + +- [ ] **Step 6: Add the agreement test** + +Add to `packages/core/test/api-request.test.ts`: + +```ts +import { REACTIVE_RUNTIME } from "../../csr/src/reactive-runtime.ts"; + +test("the browser runtime and the shared builder agree", () => { + // Cheap structural guard: the runtime must apply the same omission rule. + // If someone changes one side's rules, this fails. + expect(REACTIVE_RUNTIME).toContain('value === ""'); + expect(REACTIVE_RUNTIME).toContain("application/json"); +}); +``` + +- [ ] **Step 7: Run the tests** + +Run: `bun test packages/core packages/dev-server packages/csr` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +bun run format +git add packages/core packages/dev-server packages/csr +git commit -m "feat(core): share API request assembly between both transports" +``` + +--- + +### Task 4: Emit the server-side `api` object + +**Files:** + +- Modify: `packages/compiler/src/codegen.ts` (the server module preamble) +- Test: `packages/compiler/test/apis-server-emit.test.ts` + +**Interfaces:** + +- Consumes: `block.mode === "any"` entries from Task 1; `requireRequestContext` from Task 2; `callApiFromContext`'s new `input` parameter from Task 3. +- Produces: the generated server module declares `const api = { … }` in scope for `load` blocks, actions, and server functions. + +- [ ] **Step 1: Write the failing test** + +Create `packages/compiler/test/apis-server-emit.test.ts`: + +```ts +import { expect, test } from "bun:test"; +import { parse } from "@wrnexus/syntax"; +import { generate } from "../src/codegen.ts"; + +const SOURCE = `page Probe { + apis { + searchUsers POST /api/users { + request { body { name?: string } } + response { return data.users } + error { return [] } + } + } + + load server directory { + return await api.searchUsers({ name: "a" }) + } + + view {
x
} +} +`; + +test("the server module declares an api object with the block's path and method", () => { + const generated = generate(parse(SOURCE)); + + expect(generated).toContain("const api ="); + expect(generated).toContain("searchUsers"); + expect(generated).toContain('"/api/users"'); + expect(generated).toContain('"POST"'); +}); + +test("the response body is spliced in", () => { + expect(generate(parse(SOURCE))).toContain("data.users"); +}); + +test("a block with no error section rethrows rather than resolving undefined", () => { + const generated = generate( + parse(`page P { + apis { a GET /api/a { response { return data } } } + load server x { return await api.a() } + view {
x
} +} +`), + ); + + expect(generated).toContain("throw"); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test packages/compiler/test/apis-server-emit.test.ts` +Expected: FAIL — no `const api =` in the server module. + +- [ ] **Step 3: Emit the object** + +In `packages/compiler/src/codegen.ts`, add a helper and splice its output into the generated server module, before the load/action/function declarations so `api` is in scope for all of them: + +```ts +/** + * Server-side `api` object. + * + * The transport dispatches in-process, so a call from a load block or an action + * costs a function call rather than a network round trip. The request context + * comes from AsyncLocalStorage because `ctx` is not in scope everywhere server + * code runs. + */ +function serverApiBindings(ast: PageAst): string { + const members = ast.dataApis + .filter((block) => block.mode === "any") + .map((block) => { + const sections = block.sections!; + const response = sections.response.trim() || "return data;"; + const error = sections.error.trim(); + const failure = error + ? `const status = err.status; const message = err.message; const data = err.data; ${error}` + : `throw err;`; + + return ` ${JSON.stringify(block.name)}: async (input) => { + const ctx = __wrnexusRequireRequestContext(${JSON.stringify(`api.${block.name}`)}); + let data; + try { + data = await __wrnexusCallApi(${JSON.stringify(block.path)}, ${JSON.stringify(block.method)}, ctx, input); + } catch (err) { + ${failure} + } + ${response} + }`; + }); + + return members.length ? `const api = {\n${members.join(",\n")}\n};` : ""; +} +``` + +Note the shape: the `try` wraps **only** the transport call. The `response` body runs after it, outside the `try`, so a bug in the author's response code surfaces instead of being mistaken for a request failure. + +Import `requireRequestContext` into the generated module as `__wrnexusRequireRequestContext`, and pass `ctx` through to `__wrnexusCallApi`. + +- [ ] **Step 4: Run the tests** + +Run: `bun test packages/compiler` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +bun run format +git add packages/compiler +git commit -m "feat(compiler): emit the server-side api object" +``` + +--- + +### Task 5: Emit the browser-side `api` object, only for blocks the client calls + +**Files:** + +- Modify: `packages/compiler/src/client-codegen.ts` (`apiBindings`, ~line 319; `hasClientApi`, ~line 352) +- Test: `packages/compiler/test/apis-client-emit.test.ts` + +**Interfaces:** + +- Consumes: `block.mode === "any"` from Task 1. +- Produces: the browser module declares `const api = { … }` containing **only** blocks a client function calls. + +- [ ] **Step 1: Write the failing test** + +Create `packages/compiler/test/apis-client-emit.test.ts`: + +```ts +import { expect, test } from "bun:test"; +import { parse } from "@wrnexus/syntax"; +import { generateTargets } from "../src/targets.ts"; + +const withCalls = (calls: string) => `page Probe { + apis { + used POST /api/used { + request { body { name?: string } } + response { return data.users } + } + + unused GET /api/unused { + response { return data.secretShape } + } + } + + functions { + client async function go(): Promise { +${calls} + } + } + + view {
} +} +`; + +test("a block the client calls is emitted into the browser module", () => { + const browser = generateTargets(parse(withCalls(` await api.used({ name: "a" })`))).browser; + + expect(browser).toContain("used"); + expect(browser).toContain('"/api/used"'); +}); + +test("a block the client never calls is NOT emitted into the browser module", () => { + // Server-only transforms must not ship. This is the point of usage-driven emission. + const browser = generateTargets(parse(withCalls(` await api.used({ name: "a" })`))).browser; + + expect(browser).not.toContain("secretShape"); + expect(browser).not.toContain('"/api/unused"'); +}); + +test("no api object at all when the client calls none", () => { + const browser = generateTargets(parse(withCalls(` console.log("nothing")`))).browser; + + expect(browser).not.toContain("const api ="); +}); + +test("the emitted browser module is valid JavaScript", () => { + const browser = generateTargets(parse(withCalls(` await api.used({ name: "a" })`))).browser; + + expect(() => { + new Function(browser.replace(/^\s*import[^\n]*$/gm, "").replace(/\bexport\s+/g, "")); + }).not.toThrow(); +}); + +test("declared field types never reach the browser module", () => { + const browser = generateTargets(parse(withCalls(` await api.used({ name: "a" })`))).browser; + + expect(browser).not.toContain("name?: string"); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test packages/compiler/test/apis-client-emit.test.ts` +Expected: FAIL — `mode: "any"` blocks are not emitted, and there is no usage filter. + +- [ ] **Step 3: Find which blocks the client calls** + +Add to `packages/compiler/src/client-codegen.ts`: + +```ts +/** + * Block names the page's client functions actually call. + * + * A block's response and error bodies are page code. Emitting one the browser + * never calls would ship a server-only transform to every visitor and grow the + * bundle for nothing. + */ +function clientCalledApiNames(ast: PageAst): Set { + const called = new Set(); + const bodies = ast.runtimeFunctions + .filter((fn) => ["client", "shared"].includes(fn.runtime)) + .map((fn) => fn.body) + .join("\n"); + + for (const match of bodies.matchAll(/\bapi\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)/g)) { + called.add(match[1]!); + } + + return called; +} +``` + +- [ ] **Step 4: Emit only those blocks** + +Extend `apiBindings` to include `mode === "any"` blocks filtered by that set, keeping the existing failure shape — the two-argument `.then(onFulfilled, onRejected)`, so a bug in the response body is not swallowed by the error section: + +```ts +const called = clientCalledApiNames(ast); +const members = ast.dataApis + .filter((block) => block.sections) + .filter((block) => block.mode === "client" || (block.mode === "any" && called.has(block.name))) + .map((block) => { + /* ...existing member emission, unchanged... */ + }); +``` + +Update `hasClientApi` to use the same predicate, so the `api` reserved-binding exclusion and the emitted object can never disagree. + +- [ ] **Step 5: Run the tests** + +Run: `bun test packages/compiler` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +bun run format +git add packages/compiler +git commit -m "feat(compiler): emit browser api bindings only where the client calls them" +``` + +--- + +### Task 6: Render binding + +**Files:** + +- Modify: `packages/compiler/src/codegen.ts` (the `api="…"` attribute handling) +- Test: `packages/compiler/test/apis-render-binding.test.ts` + +**Interfaces:** + +- Consumes: Task 4's server `api` object. +- Produces: `api="name"`, `api="name()"`, and `api="name({ … })"` all resolve at render time. + +- [ ] **Step 1: Write the failing test** + +Create `packages/compiler/test/apis-render-binding.test.ts`: + +```ts +import { expect, test } from "bun:test"; +import { parse } from "@wrnexus/syntax"; +import { generate } from "../src/codegen.ts"; + +const page = (attr: string) => `page Probe { + apis { + listTeams GET /api/teams { + request { parameters { team?: string } } + response { return data.teams } + } + } + + view {

loading

} +} +`; + +test("a bare name binds", () => { + expect(generate(parse(page('api="listTeams"')))).toContain('"/api/teams"'); +}); + +test("an empty call binds identically to a bare name", () => { + const bare = generate(parse(page('api="listTeams"'))); + const called = generate(parse(page('api="listTeams()"'))); + + expect(called).toContain('"/api/teams"'); + expect(called.length).toBeGreaterThan(0); + expect(bare).toContain('"/api/teams"'); +}); + +test("an argument expression is carried into the binding", () => { + const generated = generate(parse(page('api="listTeams({ team: \\"platform\\" })"'))); + + expect(generated).toContain("platform"); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test packages/compiler/test/apis-render-binding.test.ts` +Expected: FAIL — the attribute is parsed as a plain binding name, so the call forms are not understood. + +- [ ] **Step 3: Parse the three forms** + +Where `codegen.ts` reads the `api` attribute, accept a name optionally followed by a parenthesised argument expression: + +```ts +/** `name`, `name()`, or `name({ … })`. Mirrors the shape of `@click="fn()"`. */ +function parseApiBinding(value: string): { name: string; args: string } | null { + const match = /^\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:\(([\s\S]*)\))?\s*$/.exec(value); + if (!match) return null; + return { name: match[1]!, args: (match[2] ?? "").trim() }; +} +``` + +A bare name and an empty call both yield `args: ""` — they are the same binding, which is why the test asserts they behave identically. + +- [ ] **Step 4: Emit the call with its arguments** + +The binding calls the server `api` object rather than the old marker path, passing the parsed argument expression (or nothing when empty). The result is substituted into the marker exactly as today. + +- [ ] **Step 5: Pin the double-run edge** + +The spec states plainly that a block which is both render-bound and called from code runs twice, and +that no deduplication is attempted. Add a test asserting exactly that, so the behaviour is a recorded +decision rather than an accident someone later "fixes" without knowing it was deliberate: + +```ts +test("a block that is both bound and called is invoked twice", () => { + // Deliberate: a render-time fetch and a user-triggered fetch are usually + // meant to be different requests. Collapsing them silently would be worse + // than the duplication. + const generated = generate( + parse(`page P { + apis { listTeams GET /api/teams { response { return data.teams } } } + load server x { return await api.listTeams() } + view {

loading

} +} +`), + ); + + // Both call paths are emitted: the load block's call and the binding's. + const occurrences = generated.split('"/api/teams"').length - 1; + expect(occurrences).toBeGreaterThanOrEqual(2); +}); +``` + +- [ ] **Step 6: Run the tests** + +Run: `bun test packages/compiler` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +bun run format +git add packages/compiler +git commit -m "feat(compiler): support the three api render-binding forms" +``` + +--- + +### Task 7: Generate type assertions for every block with declared fields + +**Files:** + +- Modify: `packages/cli/src/types.ts` (`apiBlockAssertions`, ~line 153) +- Test: `packages/cli/test/apis-block-types.test.ts` + +**Interfaces:** + +- Consumes: `block.mode === "any"` from Task 1. +- Produces: an assertion per block with declared fields, regardless of mode. + +The generator currently skips blocks whose `mode !== "client"`. That skip exists because an `ssr` block could never declare a `request`. Mode-less blocks invalidate the reasoning. The **zero-field skip stays** — a block with no declared fields has nothing to check, and `Record` fails spuriously against a real contract. + +- [ ] **Step 1: Write the failing test** + +Create `packages/cli/test/apis-block-types.test.ts`: + +```ts +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 { generateApplicationTypes } from "../src/types.ts"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function fixture(apisBlock: string): string { + const root = mkdtempSync(join(tmpdir(), "wrnexus-apis-types-")); + roots.push(root); + mkdirSync(join(root, "app/pages"), { recursive: true }); + mkdirSync(join(root, "app/api"), { recursive: true }); + writeFileSync( + join(root, "app/api/users.ts"), + `export const POST = async () => Response.json({ users: [] });\n`, + ); + writeFileSync( + join(root, "app/pages/search.wrn"), + `page Search {\n apis {\n${apisBlock}\n }\n\n view {
x
}\n}\n`, + ); + return root; +} + +test("a mode-less block with declared fields gets an assertion", () => { + const root = fixture(` searchUsers POST /api/users { + request { body { name?: string } } + response { return data.users } + }`); + generateApplicationTypes(root); + const generated = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8"); + + expect(generated).toContain("searchUsers"); + expect(generated).toContain('ApiInput<"/api/users", "POST">'); +}); + +test("a block with no declared fields gets no assertion", () => { + const root = fixture(` listAll GET /api/users { + response { return data.users } + }`); + generateApplicationTypes(root); + const generated = readFileSync(join(root, "app/types/wrnexus.generated.api-checks.ts"), "utf8"); + + expect(generated).not.toContain("listAll"); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test packages/cli/test/apis-block-types.test.ts` +Expected: FAIL — mode-less blocks are skipped by the `mode !== "client"` filter. + +- [ ] **Step 3: Widen the filter** + +In `packages/cli/src/types.ts`, change the skip so it drops only zero-field blocks: + +```ts +if (fields.length === 0) continue; +``` + +- [ ] **Step 4: Run the tests** + +Run: `bun test packages/cli` +Expected: PASS. + +- [ ] **Step 5: Prove the gate still bites** + +```bash +bun run --cwd examples/basic-app wrnexus generate types +bun run typecheck +``` + +Expected: PASS. Then add a field the endpoint does not accept to a block, regenerate, and confirm `typecheck` FAILS naming the assertion. Revert and confirm PASS. Paste the failing output into your report. + +- [ ] **Step 6: Commit** + +```bash +bun run format +git add packages/cli +git commit -m "feat(cli): assert types for every api block with declared fields" +``` + +--- + +### Task 8: Migrate the example and remove the old forms + +**Files:** + +- Modify: `examples/basic-app/app/pages/hello.wrn` +- Modify: `examples/basic-app/app/pages/api-block-demo.wrn` +- Modify: `packages/syntax/src/parser.ts` (reject the mode data blocks) +- Modify: `packages/compiler/src/codegen.ts`, `client-codegen.ts` (drop `mode === "ssr" | "client"` handling) +- Test: `packages/syntax/test/removed-mode-blocks.test.ts` + +**Interfaces:** + +- Consumes: everything from Tasks 1-7. +- Produces: `DataMode` becomes `"any"` only; `ssr {}` / `client {}` data blocks are a parse error. + +**These must land together.** Removing the old forms before the example is migrated leaves the repo unable to build. + +- [ ] **Step 1: Migrate `hello.wrn`** + +Its `ssr { … }` and `client { … }` blocks each contain an `api` entry and a `functions` block. Move the api entries into one `apis { }`, converting the legacy bare bodies to sectioned form — the payload binds to `data`, so `userNames(users)` becomes `userNames(data.users)`. Move the mode helpers into the page-level `functions { }` with the `shared` modifier. + +- [ ] **Step 2: Migrate `api-block-demo.wrn`** + +Its `client { api searchDirectory … }` becomes an entry in `apis { }`, unchanged otherwise. + +- [ ] **Step 3: Confirm the example still works before removing anything** + +```bash +bun run --cwd examples/basic-app build +bun test packages/compiler packages/cli +``` + +Expected: PASS. If the example does not work on the new path, do not proceed to removal. + +- [ ] **Step 4: Write the rejection test** + +Create `packages/syntax/test/removed-mode-blocks.test.ts`: + +```ts +import { expect, test } from "bun:test"; +import { parse } from "../src/index.ts"; + +test("an ssr data block is rejected and names the replacement", () => { + expect(() => + parse(`page P { + ssr { api x GET /api/x { return users } } + view {
x
} +} +`), + ).toThrow(/apis/); +}); + +test("a client data block is rejected and names the replacement", () => { + expect(() => + parse(`page P { + client { api x GET /api/x { return users } } + view {
x
} +} +`), + ).toThrow(/apis/); +}); + +test("client state is unaffected", () => { + // Different construct sharing the keyword. It must keep working. + const ast = parse(`page P { + client state { count = 0 } + view {
x
} +} +`); + + expect(ast.states.some((state) => state.name === "count")).toBe(true); +}); +``` + +- [ ] **Step 5: Reject the mode data blocks** + +In `packages/syntax/src/parser.ts`, where a `ssr` / `client` / `server` keyword is followed by `{` as a data block, throw: + +```ts +throw new ParseError( + `"${rawMode} { … }" data blocks were removed. Declare API calls in a page-level "apis { }" block, and move mode-scoped helpers into "functions { shared function … }".`, +); +``` + +Leave the `client state { … }` and `client = "…"` paths alone — they are different constructs sharing the keyword. + +- [ ] **Step 6: Drop the dead mode handling from the compiler** + +Remove the `mode === "ssr"` / `mode === "client"` branches now that no such block can be parsed, and narrow `DataMode` to `"any"`. + +- [ ] **Step 7: Run everything** + +Run: `bun test && bun run typecheck && bun run --cwd examples/basic-app build` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +bun run format +bun run --cwd editors/vscode build +git add -A packages examples editors/vscode/src +git commit -m "feat: replace the ssr/client data blocks with apis blocks" +``` + +--- + +### Task 9: End-to-end verification in a real browser + +**Files:** + +- Modify: `examples/basic-app/app/pages/api-block-demo.wrn` (add a server-side call and a render binding) + +**Interfaces:** + +- Consumes: Tasks 1-8. + +**Why this task exists:** this repository has repeatedly shipped features whose tests passed while the feature did not work. Browser verification is part of done. + +- [ ] **Step 1: Extend the demo page** + +Add a `load server` block that calls `api.searchDirectory({ name: "a" })`, and a render binding using the argument form, alongside the existing client call. + +- [ ] **Step 2: Start the dev server** + +```bash +bun run --cwd examples/basic-app dev -- --port=3480 +``` + +Never use a process-name-wide kill to stop it; stop only the PID listening on 3480. Do not touch port 3000. + +- [ ] **Step 3: Verify the client call** + +Open `http://localhost:3480/api-block-demo`, click Search. Expected: the result renders, exactly one `POST /api/directory` in the network panel, carrying `x-csrf-token`. + +- [ ] **Step 4: Verify the server call made no network request** + +The `load server` result must appear in the server-rendered HTML (check with `curl`, before any JavaScript runs), and no corresponding request should appear in the browser's network panel. That is the evidence dispatch really is in-process. + +- [ ] **Step 5: Verify the render binding** + +The bound element's content must be present in the `curl` output. + +- [ ] **Step 6: Verify the failure path** + +Point a block at a missing route, reload, and confirm the `error` section's fallback renders with no unhandled exception. Restore the path. + +- [ ] **Step 7: Full gate** + +```bash +bun run format +bun test +bun run typecheck +bun run --cwd editors/vscode build +bun run check:production +``` + +Regenerate `generate:public-api` and the example's types if the gate reports them stale, and confirm the public-API diff is intentional. + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "feat(examples): worked example for apis blocks" +``` + +--- + +## Notes for the executor + +- **`REACTIVE_RUNTIME` is a template literal.** A backtick in code or a comment you add to it terminates the string and produces a confusing error elsewhere in the file. +- **The `try` must wrap only the transport call.** If it also wraps the response body, a bug in the author's code silently takes the error path — a defect this project has already fixed once. +- **Adding an export makes `check:public-api` fail** until you run `bun run generate:public-api` and confirm the diff is intentional. +- **If a test would still pass with the code it guards deleted, it is not a test.** Delete the implementation, watch it fail, restore it. diff --git a/docs/superpowers/plans/2026-08-19-editor-tooling.md b/docs/superpowers/plans/2026-08-19-editor-tooling.md new file mode 100644 index 00000000..80e5c2bf --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-editor-tooling.md @@ -0,0 +1,400 @@ +# Editor Tooling Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** The language server and VS Code extension understand `apis { }`, complete `api.()`, flag the removed constructs, and stop offering syntax the compiler rejects. + +**Architecture:** Four surfaces change independently — the TextMate grammar, the keyword and snippet completions, the server's call completion and hover, and diagnostics for removed constructs. A final task answers by observation whether generated type errors surface inside `.wrn`, which has never been verified. + +**Tech Stack:** Bun, TypeScript, `bun:test`, `node --test`, LSP, TextMate grammars. + +**Spec:** `docs/superpowers/specs/2026-08-19-editor-tooling-design.md` + +## Global Constraints + +- `client` is one word with several jobs: `client state { }`, `runtime = "client"`, and the `client function` modifier all survive. **Only the `client { }` / `ssr { }` data-block patterns are removed.** Blanket removal would un-highlight constructs that still exist. +- Offering a construct the compiler rejects is worse than offering nothing. +- **Nothing may claim inline diagnostics work until someone has seen them work.** +- The editor bundles embed the compiler and language server — rebuild with `bun run --cwd editors/vscode build`, or the `check:editor-*` gates fail on a stale bundle. +- `bun run format` before every commit; the gate is `bun run check:production`. +- Do NOT use `node -e`, shell heredocs, or `sed` to write code into files. + +--- + +### Task 1: Grammar + +**Files:** + +- Modify: `editors/vscode/syntaxes/wrn.tmLanguage.json` +- Test: `editors/vscode/test/grammar-apis.test.js` + +**Interfaces:** + +- Produces: `apis` highlights as a block keyword; entries highlight as declarations. + +- [ ] **Step 1: Write the failing test** + +Create `editors/vscode/test/grammar-apis.test.js`: + +```js +"use strict"; + +const assert = require("node:assert"); +const { test } = require("node:test"); +const { readFileSync } = require("node:fs"); +const { join } = require("node:path"); + +const grammar = readFileSync(join(__dirname, "../syntaxes/wrn.tmLanguage.json"), "utf8"); + +test("the grammar knows the apis block", () => { + assert.ok(grammar.includes("apis"), "apis should appear as a block keyword"); +}); + +test("client keeps its highlighting where it is still valid", () => { + // client state {}, runtime = "client", and client function all survive. + // Only the client {} data block was removed. + assert.ok(grammar.includes("client"), "client must still be matched"); + assert.ok(grammar.includes("shared"), "the shared function modifier must still be matched"); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `node --test editors/vscode/test/grammar-apis.test.js` +Expected: FAIL on the first assertion — `apis` is absent. + +- [ ] **Step 3: Add `apis`, remove only the data-block patterns** + +Add `apis` to the block-keyword pattern alongside `functions`. Then find the patterns matching `ssr`/`client` as **data blocks** and remove only those. Leave every rule that matches `client` in `client state`, in `runtime` values, and as a function modifier. + +Add a pattern for an entry — ` ` — so a declaration reads as a declaration. + +- [ ] **Step 4: Run the test and check by eye** + +Run: `node --test editors/vscode/test/grammar-apis.test.js` +Expected: PASS. Then open a `.wrn` file using `apis { }`, `client state { }`, `functions { shared function }`, and `runtime = "client"` in VS Code and confirm each still colours correctly. Record what you saw. + +- [ ] **Step 5: Commit** + +```bash +bun run format +git add editors/vscode +git commit -m "feat(editor): highlight the apis block" +``` + +--- + +### Task 2: Keyword and snippet completion + +**Files:** + +- Modify: `packages/language-server/src/index.ts` (`WRN_KEYWORDS`, ~line 31) +- Modify: `editors/vscode/src/completion.js` (block snippets) +- Test: `packages/language-server/test/apis-completion.test.ts` + +**Interfaces:** + +- Consumes: nothing. +- Produces: `apis` is a known keyword; `ssr` / `client` data-block snippets are gone. + +- [ ] **Step 1: Write the failing test** + +Create `packages/language-server/test/apis-completion.test.ts`: + +```ts +import { expect, test } from "bun:test"; +import { WRN_KEYWORDS } from "../src/index.ts"; + +test("apis is a known page-level keyword", () => { + expect(WRN_KEYWORDS).toContain("apis"); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test packages/language-server/test/apis-completion.test.ts` +Expected: FAIL — `apis` is missing. + +- [ ] **Step 3: Add the keyword and the snippets** + +Add `"apis"` to `WRN_KEYWORDS`. In `editors/vscode/src/completion.js`, add a container snippet and an entry snippet including the `request` / `response` / `error` sections, and remove any `ssr {` / `client {` data-block snippet. + +- [ ] **Step 4: Run the tests** + +Run: `bun test packages/language-server && bun run --cwd editors/vscode test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +bun run format +git add packages/language-server editors/vscode +git commit -m "feat(editor): complete the apis block and drop the removed snippets" +``` + +--- + +### Task 3: `api.` call completion and hover + +**Files:** + +- Modify: `packages/language-server/src/server.ts` (completion and hover handlers) +- Test: `packages/language-server/test/api-call-completion.test.ts` + +**Interfaces:** + +- Consumes: `ast.dataApis` entries, which carry `name`, `method`, `path`, and `sections`. +- Produces: completion items for `api.` and hover detail for a block name. + +This is where the syntax pays off in the editor: the set of legal calls is knowable, so the editor should know it. + +- [ ] **Step 1: Write the failing test** + +Create `packages/language-server/test/api-call-completion.test.ts`: + +```ts +import { expect, test } from "bun:test"; +import { apiCallCompletions, apiCallHover } from "../src/server.ts"; + +const SOURCE = `page Search { + apis { + searchUsers POST /api/users { + request { body { name?: string } } + response { return data.users } + } + + listTeams GET /api/teams { + response { return data.teams } + } + } + + functions { + client async function go(): Promise { + await api. + } + } + + view {
x
} +} +`; + +test("api. offers every declared block with method and path", () => { + const items = apiCallCompletions(SOURCE); + const labels = items.map((item) => item.label); + + expect(labels).toContain("searchUsers"); + expect(labels).toContain("listTeams"); + + const search = items.find((item) => item.label === "searchUsers")!; + expect(search.detail).toContain("POST"); + expect(search.detail).toContain("/api/users"); +}); + +test("hovering a block name reports its method, path and request fields", () => { + const hover = apiCallHover(SOURCE, "searchUsers"); + + expect(hover).toContain("POST"); + expect(hover).toContain("/api/users"); + expect(hover).toContain("name"); +}); + +test("a page with no apis block offers nothing", () => { + expect(apiCallCompletions(`page P { view {
x
} }`)).toEqual([]); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test packages/language-server/test/api-call-completion.test.ts` +Expected: FAIL — the functions do not exist. + +- [ ] **Step 3: Implement and export both functions** + +Add `apiCallCompletions(source: string)` and `apiCallHover(source: string, name: string)` to `packages/language-server/src/server.ts`, parsing with `@wrnexus/syntax` and reading `ast.dataApis`. Wire `apiCallCompletions` into the `textDocument/completion` handler for positions immediately after `api.`, and `apiCallHover` into `textDocument/hover`. + +The parser must tolerate the half-typed `await api.` in the fixture. If it throws, fall back to returning `[]` rather than failing the request — completion fires while the document does not parse, which is the normal case. + +- [ ] **Step 4: Run the tests** + +Run: `bun test packages/language-server` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +bun run format +git add packages/language-server +git commit -m "feat(language-server): complete and describe api block calls" +``` + +--- + +### Task 4: The `api=` attribute in markup + +**Files:** + +- Modify: `packages/language-server/src/html-service.ts` +- Test: `packages/language-server/test/api-attribute.test.ts` + +**Interfaces:** + +- Consumes: `apiCallCompletions` from Task 3. +- Produces: `api="…"` is not flagged as unknown, and completion inside the quotes offers block names. + +- [ ] **Step 1: Write the failing test** + +Create `packages/language-server/test/api-attribute.test.ts` asserting that (a) an `api="searchUsers"` attribute produces no unknown-attribute diagnostic, and (b) completion inside the quotes offers `searchUsers`. Reuse the fixture shape from Task 3. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test packages/language-server/test/api-attribute.test.ts` +Expected: FAIL. + +- [ ] **Step 3: Teach the HTML service about the attribute** + +Treat `api` as a known attribute on any element, and route completion inside its quotes to `apiCallCompletions`. The value is a call expression, not text — it must not be spell-checked or reformatted as prose. + +- [ ] **Step 4: Run the tests and commit** + +```bash +bun test packages/language-server +bun run format +git add packages/language-server +git commit -m "feat(language-server): understand the api binding attribute" +``` + +--- + +### Task 5: Diagnostics for the removed constructs + +**Files:** + +- Modify: `packages/language-server/src/diagnostics` entry point (wherever `.wrn` diagnostics are produced) +- Test: `packages/language-server/test/removed-construct-diagnostics.test.ts` + +**Interfaces:** + +- Produces: an `ssr { api … }` or `client { api … }` block yields a diagnostic naming `apis { }`, positioned on the block keyword. + +The compiler already rejects these. The editor should say so while typing, and say what to do instead. + +- [ ] **Step 1: Write the failing test** + +Create `packages/language-server/test/removed-construct-diagnostics.test.ts`: + +```ts +import { expect, test } from "bun:test"; +import { diagnoseWrn } from "../src/index.ts"; + +test("an ssr data block is flagged and names the replacement", () => { + const diagnostics = diagnoseWrn(`page P { + ssr { api x GET /api/x { response { return data } } } + view {
x
} +} +`); + + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics[0]!.message).toContain("apis"); +}); + +test("client state is not flagged", () => { + const diagnostics = diagnoseWrn(`page P { + client state { count = 0 } + view {
x
} +} +`); + + expect(diagnostics.filter((item) => item.severity === 1)).toEqual([]); +}); +``` + +Use whichever diagnostic entry point the language server exports; keep the assertions identical. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test packages/language-server/test/removed-construct-diagnostics.test.ts` +Expected: FAIL — either no diagnostic, or one that does not name `apis`. + +- [ ] **Step 3: Surface the parse error as a diagnostic** + +The parser already throws a message naming `apis { }` for these blocks. Ensure that message reaches the diagnostic with a position on the offending keyword rather than at offset zero. + +- [ ] **Step 4: Run the tests and commit** + +```bash +bun test packages/language-server +bun run format +git add packages/language-server +git commit -m "feat(language-server): flag the removed data blocks" +``` + +--- + +### Task 6: Answer the inline-diagnostics question, then the full gate + +**Files:** + +- Modify: whatever the observation in Step 2 shows is needed, or none + +**Interfaces:** + +- Consumes: Tasks 1-5. + +The `apis` plan generates type assertions that make `tsc` fail when a block declares a field its endpoint rejects. **Whether that failure appears inside the `.wrn` file has never been confirmed** — it was inferred from reading source. This task settles it by looking. + +- [ ] **Step 1: Rebuild the bundles** + +```bash +bun run --cwd editors/vscode build +``` + +- [ ] **Step 2: Observe, and write down what you see** + +In `examples/basic-app`, add a field to an `apis { }` entry that its endpoint does not accept, and run `bun run --cwd examples/basic-app wrnexus generate types`. Open the page in VS Code and record exactly where the error appears: on the block, only in `app/types/wrnexus.generated.api-checks.ts`, or nowhere. + +Write the answer into the task report. **Do not skip this step and reason about it instead** — that is what left the question open the first time. + +- [ ] **Step 3: Act on what you observed** + +If the error already surfaces usefully on the block, document it and stop. + +If it appears only in the generated file, map the diagnostic back: the generator knows which page and block produced each assertion, so record that mapping when emitting and use it to relocate the diagnostic. + +If that mapping proves larger than this task can hold, **stop and report it as follow-up work** rather than half-building it. Say so plainly in the report. + +- [ ] **Step 4: Remove the temporary field** + +Revert the deliberate error and confirm `bun run typecheck` passes with zero net diff in `examples/basic-app`. + +- [ ] **Step 5: Full gate** + +```bash +bun run format +bun test +bun run typecheck +bun run --cwd editors/vscode build +bun run --cwd editors/vscode test +bun run check:production +``` + +Expected: exit 0 throughout, including `check:editor-compiler`, `check:editor-language-server`, and `check:editor-extension`. + +- [ ] **Step 6: Manual pass, recorded** + +Open the migrated `examples/basic-app` in VS Code and confirm: `apis { }` highlights, `api.` completes with the page's block names, hovering a name shows its method and path, and an `ssr { api … }` block is flagged. Record what you saw in the report — including anything that did not work. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "feat(editor): complete tooling support for the apis block" +``` + +--- + +## Notes for the executor + +- **`client` is not one thing.** Removing every `client` rule from the grammar would break `client state`, `runtime = "client"`, and `client function`. Only the data-block patterns go. +- **The parser must tolerate half-typed input.** Completion fires while the document does not parse; a thrown error must become an empty completion list, not a failed request. +- **Step 2 of Task 6 is an observation, not a deduction.** Open the editor and look. +- **If a test would still pass with the code it guards deleted, it is not a test.** diff --git a/docs/superpowers/plans/2026-08-19-legacy-and-config-cleanup.md b/docs/superpowers/plans/2026-08-19-legacy-and-config-cleanup.md new file mode 100644 index 00000000..db4d455e --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-legacy-and-config-cleanup.md @@ -0,0 +1,537 @@ +# Legacy and Config Cleanup Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Delete the compatibility-flag surface, the `"legacy"` function runtime, dead migrations, and deprecated APIs before the framework's first public release. + +**Architecture:** Almost all of this is deletion. Seven config keys are never read by any code, so removing them changes nothing. The one behaviour-sensitive item is the `"legacy"` function runtime, which is mapped to `"shared"` — an equivalent substitution, because an unmarked function is already emitted into both bundles. + +**Tech Stack:** Bun, TypeScript, `bun:test`. + +**Spec:** `docs/superpowers/specs/2026-08-19-legacy-and-config-cleanup-design.md` + +## Global Constraints + +- This plan removes configuration. It adds none. +- The legacy `api` block forms (bare-body `with ($data)`, and `api` inside `ssr {}` / `client {}`) are **out of scope** — they are replaced by the next plan, not deleted here. +- A removed config key must be **rejected loudly**, not silently ignored. Someone with a stale config must be told, not left believing a flag still applies. +- `bun run format` before every commit; the repo gate is `bun run check:production`. +- The editor bundles embed the compiler — rebuild with `bun run --cwd editors/vscode build` after any `packages/syntax` or `packages/compiler` change, or `check:editor-compiler` fails on a stale bundle. +- Do NOT use `node -e`, shell heredocs, or `sed` to write code into files; escaping mangles them silently. Use file editing tools. + +--- + +### Task 1: Replace the `"legacy"` function runtime with `"shared"` + +**Files:** + +- Modify: `packages/syntax/src/v060.ts` (the `FunctionRuntime` type; the default at ~line 209) +- Modify: `packages/compiler/src/client-codegen.ts` (membership tests at ~lines 173 and 340) +- Modify: `packages/compiler/src/server-codegen.ts` (the `["legacy", "server", "shared"]` list) +- Modify: `packages/compiler/src/codegen.ts` (`targetFunctions`, ~line 1310) +- Test: `packages/compiler/test/legacy-runtime-removal.test.ts` + +**Interfaces:** + +- Produces: `FunctionRuntime` becomes `"client" | "server" | "shared"`. Later tasks and plans rely on `"legacy"` no longer existing. + +**Why this is equivalent, not a behaviour change:** an unmarked `function foo()` currently parses as `"legacy"`, and both codegens include `"legacy"` in their membership tests — `["legacy", "client", "shared"]` for the browser and `["legacy", "server", "shared"]` for the server. So an unmarked function is already emitted into _both_ bundles, exactly like `shared`. `legacyDefaultRuntime` looks like it should modulate this but is never read. + +- [ ] **Step 1: Write the failing test** + +Create `packages/compiler/test/legacy-runtime-removal.test.ts`: + +```ts +import { expect, test } from "bun:test"; +import { parse } from "@wrnexus/syntax"; +import { generateTargets } from "../src/targets.ts"; + +const SOURCE = `page Probe { + functions { + function unmarkedHelper() { + return "both"; + } + + client function clientOnly() { + return "browser"; + } + + server function serverOnly() { + return "server"; + } + } + + view {
x
} +} +`; + +test("an unmarked function is emitted into both the browser and server modules", () => { + // This is the property the "legacy" runtime provided. Removing the variant + // must not change it. + const targets = generateTargets(parse(SOURCE)); + + expect(targets.browser).toContain("unmarkedHelper"); + expect(targets.server).toContain("unmarkedHelper"); +}); + +test("marked functions still go only where they belong", () => { + const targets = generateTargets(parse(SOURCE)); + + expect(targets.browser).toContain("clientOnly"); + expect(targets.browser).not.toContain("serverOnly"); + expect(targets.server).toContain("serverOnly"); + expect(targets.server).not.toContain("clientOnly"); +}); + +test("no emitted target mentions the removed legacy runtime", () => { + const targets = generateTargets(parse(SOURCE)); + + expect(targets.browser).not.toContain('"legacy"'); + expect(targets.server).not.toContain('"legacy"'); +}); +``` + +- [ ] **Step 2: Run the test and record the baseline** + +Run: `bun test packages/compiler/test/legacy-runtime-removal.test.ts` +Expected: the first two tests PASS (they describe current behaviour and must keep passing), the third may already pass. This test file is a **regression guard written before the change**, so a green run here is correct — record the output. + +- [ ] **Step 3: Remove the `"legacy"` variant from the type and parser** + +In `packages/syntax/src/v060.ts`: + +```ts +export type FunctionRuntime = "client" | "server" | "shared"; +``` + +And at the parse site (~line 209), change the default: + +```ts +let runtime: FunctionRuntime = "shared"; +``` + +- [ ] **Step 4: Drop `"legacy"` from every membership test** + +In `packages/compiler/src/client-codegen.ts`, both occurrences: + +```ts + ["client", "shared"].includes(fn.runtime), +``` + +In `packages/compiler/src/server-codegen.ts`: + +```ts +const names = ast.runtimeFunctions + .filter((fn) => ["server", "shared"].includes(fn.runtime)) + .map((fn) => fn.name); +``` + +In `packages/compiler/src/codegen.ts`, `targetFunctions`: + +```ts +const runtimes = + target === "browser" ? (["client", "shared"] as const) : (["server", "shared"] as const); +``` + +Search the repo for any remaining `"legacy"` in these packages and remove each — the string must not survive in `packages/syntax` or `packages/compiler`. + +- [ ] **Step 5: Run the tests** + +Run: `bun test packages/syntax packages/compiler` +Expected: PASS, including the three guards from Step 1. If the first two now fail, the substitution was not equivalent — stop and report rather than adjusting the test. + +- [ ] **Step 6: Rebuild the editor bundles and commit** + +```bash +bun run format +bun run --cwd editors/vscode build +git add packages/syntax packages/compiler editors/vscode/src +git commit -m "refactor: replace the legacy function runtime with shared" +``` + +--- + +### Task 2: Delete the compatibility surface + +**Files:** + +- Delete: `packages/styles/src/compatibility.ts` +- Delete: `packages/cli/src/compatibility-command.ts` +- Delete: `packages/cli/test/compatibility.test.ts` +- Modify: `packages/styles/src/config.ts` (`FunctionsConfig` ~233, `CompatibilityConfig` ~242, `AppConfig extends CompatibilityPolicy` ~249, the `functions?:` and `compatibility?:` members, the `resolveCompatibility` validation ~619, and the `CompatibilityPolicy` import ~23) +- Modify: `packages/styles/src/index.ts` (the `./compatibility.ts` exports at ~lines 39-45) +- Modify: `packages/cli/src/index.ts` (dispatch at ~line 295, help text at ~line 75) +- Modify: `packages/cli/src/create.ts` (~lines 263, 278-283) +- Modify: `packages/cli/src/update.ts` (the config insertion string at ~line 389) +- Modify: `packages/styles/test/config.test.ts` (assertions on the removed keys) +- Modify: `examples/basic-app/wrnexus.config.ts` +- Test: `packages/styles/test/removed-config-keys.test.ts` + +**Interfaces:** + +- Consumes: nothing from Task 1. +- Produces: `AppConfig` no longer extends `CompatibilityPolicy` and has no `compatibility` or `functions` members. `@wrnexus/styles` no longer exports `resolveCompatibility`, `isCompatibilityDate`, `CURRENT_COMPATIBILITY_DATE`, `CURRENT_FRAMEWORK_BEHAVIOUR`, `CompatibilityPolicy`, or `CompatibilityReport`. + +**These seven keys are never read.** `legacyEmit`, `legacyEventProps`, `legacyComponentDiscovery`, `stringLayouts`, and `legacyDefaultRuntime` appear only in the type declaration, `create.ts`, and `update.ts`. `compatibilityDate` and `frameworkBehaviour` feed only a printed report and one validation. Removing them changes no behaviour. + +- [ ] **Step 1: Write the failing test** + +Create `packages/styles/test/removed-config-keys.test.ts`: + +```ts +import { expect, test } from "bun:test"; +import { validateConfig } from "../src/config.ts"; + +// A stale config must fail loudly. Silently ignoring a removed key leaves +// someone believing a flag still applies. +const REMOVED = [ + { key: "compatibilityDate", config: { compatibilityDate: "2026-08-02" } }, + { key: "frameworkBehaviour", config: { frameworkBehaviour: 1 } }, + { key: "functions", config: { functions: { legacyDefaultRuntime: "current" } } }, + { key: "compatibility", config: { compatibility: { legacyEmit: false } } }, +]; + +for (const { key, config } of REMOVED) { + test(`a config still setting "${key}" is rejected with a message naming it`, () => { + const issues = validateConfig(config as never); + const match = issues.find((issue) => issue.path === key || issue.path.startsWith(`${key}.`)); + + expect(match).toBeDefined(); + expect(match!.severity).toBe("error"); + expect(match!.message.toLowerCase()).toContain("removed"); + }); +} + +test("a config without those keys is accepted", () => { + const issues = validateConfig({} as never); + + expect(issues.filter((issue) => issue.severity === "error")).toEqual([]); +}); +``` + +If `validateConfig` is not the exported name in `packages/styles/src/config.ts`, use whichever function that module exports for validation and keep the assertions identical. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test packages/styles/test/removed-config-keys.test.ts` +Expected: FAIL — the keys are currently accepted, so no issue is produced. + +- [ ] **Step 3: Delete the compatibility module and its command** + +```bash +git rm packages/styles/src/compatibility.ts packages/cli/src/compatibility-command.ts packages/cli/test/compatibility.test.ts +``` + +In `packages/styles/src/index.ts`, remove the whole `./compatibility.ts` export block (both the value exports and the `export type` line). + +In `packages/cli/src/index.ts`, remove the `case "compatibility":` dispatch and the `wrnexus compatibility …` line from the help text. + +- [ ] **Step 4: Remove the config members and add the rejections** + +In `packages/styles/src/config.ts`: delete the `CompatibilityPolicy` import, the `FunctionsConfig` and `CompatibilityConfig` interfaces, the `functions?:` and `compatibility?:` members of `AppConfig`, `extends CompatibilityPolicy` on `AppConfig`, and the `resolveCompatibility` validation block. + +Then add the rejections so a stale config fails loudly: + +```ts +const REMOVED_CONFIG_KEYS = [ + "compatibilityDate", + "frameworkBehaviour", + "functions", + "compatibility", +] as const; + +for (const key of REMOVED_CONFIG_KEYS) { + if ((config as Record)[key] !== undefined) { + issues.push({ + path: key, + severity: "error", + message: "was removed; delete it from the configuration", + }); + } +} +``` + +Place this beside the other validation pushes, using whatever local variable that function accumulates issues in. + +- [ ] **Step 5: Stop scaffolding and inserting the keys** + +In `packages/cli/src/create.ts`, delete the `compatibilityDate`, `frameworkBehaviour`, and `functions: { legacyDefaultRuntime: … }` lines from the generated config. + +In `packages/cli/src/update.ts` (~line 389), remove `functions: { legacyDefaultRuntime: "current" },` and the whole `compatibility: { … },` fragment from the insertion string. + +- [ ] **Step 6: Trim the example app config** + +In `examples/basic-app/wrnexus.config.ts`, delete `compatibilityDate`, `frameworkBehaviour`, `functions`, and `compatibility`. + +- [ ] **Step 7: Update the existing config tests** + +`packages/styles/test/config.test.ts` asserts on the removed keys. Remove those assertions. Do not weaken any assertion that is still meaningful — if a test only existed to cover compatibility, delete the whole test. + +- [ ] **Step 8: Run the tests** + +Run: `bun test packages/styles packages/cli` +Expected: PASS, including the new rejection tests. + +- [ ] **Step 9: Commit** + +```bash +bun run format +git add -A packages/styles packages/cli examples/basic-app +git commit -m "refactor: delete the compatibility config surface" +``` + +--- + +### Task 3: Drop migrations below 0.8.0 + +**Files:** + +- Modify: `packages/cli/src/update.ts` (all `Migration` entries with `version` below `"0.8.0"`) +- Test: `packages/cli/test/update-migration-floor.test.ts` + +**Interfaces:** + +- Consumes: nothing. +- Produces: the migration list starts at `0.8.0`. + +`update.ts` holds 111 migrations reaching back to `0.2.8`. The framework is pre-public and the only projects run `0.8.x`, so everything below the floor is unreachable. + +- [ ] **Step 1: Write the failing test** + +Create `packages/cli/test/update-migration-floor.test.ts`: + +```ts +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +test("no migration targets a version below 0.8.0", () => { + const source = readFileSync(join(import.meta.dir, "../src/update.ts"), "utf8"); + const versions = [...source.matchAll(/version:\s*"([0-9.]+)"/g)].map((match) => match[1]!); + + expect(versions.length).toBeGreaterThan(0); + + const belowFloor = versions.filter((version) => { + const [major, minor] = version.split(".").map(Number); + return major! === 0 && minor! < 8; + }); + + expect(belowFloor).toEqual([]); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test packages/cli/test/update-migration-floor.test.ts` +Expected: FAIL, listing the `0.2.x`–`0.7.x` versions. + +- [ ] **Step 3: Delete the migrations below the floor** + +Remove every `Migration` object whose `version` is below `"0.8.0"`, along with any helper function that becomes unused as a result. Keep every `0.8.x` entry. + +After deleting, search for now-unreferenced helpers in the file and remove them too — an unused private helper is dead code, and the linter will flag it. + +- [ ] **Step 4: Run the tests** + +Run: `bun test packages/cli` +Expected: PASS. Existing update tests that exercised removed migrations should be deleted with them; do not keep a test that asserts nothing. + +- [ ] **Step 5: Verify `update` still runs end to end** + +```bash +bun run --cwd examples/basic-app wrnexus update --dry-run +``` + +Expected: completes without error and reports no pending migrations for an app already at the current version. Paste the output into the commit body if it is short. + +- [ ] **Step 6: Commit** + +```bash +bun run format +git add packages/cli +git commit -m "chore: drop update migrations below 0.8.0" +``` + +--- + +### Task 4: Remove the deprecated compiler re-export shims + +**Files:** + +- Modify: `packages/compiler/src/codegen.ts` (~line 20, the `./parser.ts` import) +- Modify: `packages/compiler/src/native-codegen.ts` (~line 1, the `./parser.ts` import) +- Delete: `packages/compiler/src/parser.ts`, `packages/compiler/src/tokenizer.ts`, `packages/compiler/src/types.ts` + +**Interfaces:** + +- Consumes: nothing. +- Produces: nothing new; imports move to `@wrnexus/syntax`. + +**Order matters.** These three files are two-line re-exports marked deprecated, but `codegen.ts` and `native-codegen.ts` still import from them. Deleting the files first breaks the build. + +- [ ] **Step 1: Repoint the imports** + +In `packages/compiler/src/codegen.ts`, change: + +```ts +import { VOID_ELEMENTS, type Attr, type DataMode, type PageAst, type ViewNode } from "./parser.ts"; +``` + +to import the same names from `@wrnexus/syntax`. If the file already imports from `@wrnexus/syntax`, merge them into that one import rather than adding a second. + +In `packages/compiler/src/native-codegen.ts`, change: + +```ts +import type { Attr, PageAst, ViewNode } from "./parser.ts"; +``` + +the same way. + +- [ ] **Step 2: Verify nothing else imports the shims** + +```bash +grep -rn "from \"./parser.ts\"\|from \"./tokenizer.ts\"\|from \"./types.ts\"" packages/compiler/src/ +``` + +Expected: no output. If anything remains, repoint it before continuing. + +- [ ] **Step 3: Delete the shims** + +```bash +git rm packages/compiler/src/parser.ts packages/compiler/src/tokenizer.ts packages/compiler/src/types.ts +``` + +- [ ] **Step 4: Run the tests** + +Run: `bun test packages/compiler && bun run typecheck` +Expected: PASS. + +- [ ] **Step 5: Rebuild the editor bundles and commit** + +```bash +bun run format +bun run --cwd editors/vscode build +git add -A packages/compiler editors/vscode/src +git commit -m "refactor: drop the deprecated compiler re-export shims" +``` + +--- + +### Task 5: Remove the deprecated `@wrnexus/auth` options + +**Files:** + +- Modify: `packages/auth/src/http/index.ts` (~lines 56-59) +- Modify: `packages/auth/src/plugin.ts` (~lines 55-58) +- Modify: `packages/auth/src/types.ts` (~line 423) +- Modify: `packages/auth/src/engine.ts` (~lines 163-165 and 179-181) +- Modify: `packages/auth/test/http.test.ts`, `packages/auth/test/plugin.test.ts`, `packages/auth/test/engine.test.ts` + +**Interfaces:** + +- Consumes: nothing. +- Produces: nothing new. Options are removed, not renamed. + +**These are our own superseded options, not an out-of-date dependency.** The current form is already what `examples/auth-showcase/app/lib/auth.ts` uses — it passes `onSignedIn` / `onSignedOut` to `createAuthEngine`, which is correct and must not change. The deprecated members are the same names on _different_ option objects. + +- [ ] **Step 1: Confirm the blast radius before deleting** + +```bash +grep -rn "onSuccessfullSignUp" packages/ examples/ services/ | grep -v dist/ +grep -rn "onSignedIn\|onSignedOut" packages/ examples/ --include=*.ts | grep -v "packages/auth/src" | grep -v dist/ +``` + +Expected: `onSuccessfullSignUp` has zero references. The `onSignedIn` / `onSignedOut` hits are `packages/auth/test/http.test.ts`, `packages/auth/test/plugin.test.ts`, and `examples/auth-showcase/app/lib/auth.ts`. **The example is the correct `createAuthEngine` form and must be left alone.** Record what you found; if the results differ from this, stop and report before deleting anything. + +- [ ] **Step 2: Remove the option declarations** + +Delete `onSignedIn` and `onSignedOut` (and their `@deprecated` comments) from the options interface in `packages/auth/src/http/index.ts` and from `packages/auth/src/plugin.ts`. Delete `onSuccessfullSignUp` from `packages/auth/src/types.ts`. Delete the `rpId` and `origin` members from both verification signatures in `packages/auth/src/engine.ts`. + +Then remove the code that reads them. The `rpId` / `origin` values are already ignored — verification uses the values bound to the issued challenge — so removing them changes no behaviour. + +- [ ] **Step 3: Update the tests that exercised the deprecated paths** + +`packages/auth/test/http.test.ts` and `plugin.test.ts` pass the deprecated options. Rewrite each to use the `createAuthEngine` form where the test is still meaningful, and delete the test where its only purpose was to cover the deprecated alias. + +`packages/auth/test/engine.test.ts` passes `rpId` / `origin` to verification. Remove those arguments; the assertions on the verification result should be unchanged, which is the evidence that the options were inert. + +- [ ] **Step 4: Run the tests** + +Run: `bun test packages/auth && bun run typecheck` +Expected: PASS. + +- [ ] **Step 5: Confirm no `@deprecated` markers remain in auth** + +```bash +grep -rn "@deprecated" packages/auth/src/ +``` + +Expected: no output. + +- [ ] **Step 6: Commit** + +```bash +bun run format +git add -A packages/auth +git commit -m "refactor: remove the deprecated auth options" +``` + +--- + +### Task 6: Full gate + +**Files:** + +- Modify: whatever the gate reports as stale (generated types, public API baseline, editor bundles) + +**Interfaces:** + +- Consumes: Tasks 1-5. +- Produces: a green `check:production`. + +- [ ] **Step 1: Rebuild the editor bundles** + +```bash +bun run --cwd editors/vscode build +``` + +The compiler and language server are embedded there and both changed. + +- [ ] **Step 2: Run the full gate** + +```bash +bun run format +bun test +bun run typecheck +bun run check:production +``` + +- [ ] **Step 3: Regenerate anything the gate reports as stale** + +`check:public-api` fails when exports change — and this plan removed several from `@wrnexus/styles`. Run `bun run generate:public-api`, then **read the diff and confirm it is removals only**. An unexpected addition means something was exported by accident. + +`check:generated-types` may need `bun run --cwd examples/basic-app wrnexus generate types`. + +- [ ] **Step 4: Re-run the gate until green** + +```bash +bun run check:production +``` + +Expected: exit 0. + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "chore: regenerate baselines after the legacy cleanup" +``` + +--- + +## Notes for the executor + +- **The seven config keys are dead.** If you find code that actually reads one, stop and report — the spec's central claim would be wrong and the plan needs revisiting. +- **Task 1 is the only behaviour-sensitive change.** Its first two tests describe current behaviour and must pass both before and after. If they fail after, the substitution was not equivalent; report rather than editing the test. +- **The auth example is already correct.** `examples/auth-showcase` uses `createAuthEngine({ onSignedIn })`, which is the current API, not the deprecated one. +- **If a test would still pass with the code it guards deleted, it is not a test.** Delete the implementation, watch it fail, restore it. diff --git a/docs/superpowers/plans/2026-08-19-update-migration.md b/docs/superpowers/plans/2026-08-19-update-migration.md new file mode 100644 index 00000000..30a46819 --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-update-migration.md @@ -0,0 +1,479 @@ +# `wrnexus update` Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** One `wrnexus update` carries an existing project from today's syntax to the syntax left by the cleanup and `apis { }` plans — or refuses precisely, naming the file and the reason. + +**Architecture:** These are new `Migration` entries in the existing `update.ts` framework, which already has dry-run support and a report that separates automatic changes from ones needing review. `.wrn` rewriting parses with `@wrnexus/syntax` and re-emits through `formatWrn`, both already imported there. + +**Tech Stack:** Bun, TypeScript, `bun:test`, `@wrnexus/syntax`. + +**Spec:** `docs/superpowers/specs/2026-08-19-update-migration-design.md` + +## Global Constraints + +- **A file is transformed correctly, or it is left untouched and reported.** There is no third outcome — never a partial rewrite. +- Every migration is **idempotent**: running it twice changes nothing the second time. +- **Dry-run reports exactly what a real run would change**, and writes nothing. +- A run with anything in `needsReview` or `parseFailures` **exits non-zero**, so a scripted upgrade cannot appear to succeed while leaving a project half-migrated. +- Migrations attach to the release that ships the breaking change, above the `0.8.0` floor. +- `bun run format` before every commit; the gate is `bun run check:production`. +- Do NOT use `node -e`, shell heredocs, or `sed` to write code into files. + +--- + +### Task 1: Remove the dead config keys + +**Files:** + +- Modify: `packages/cli/src/update.ts` (add a `Migration`) +- Test: `packages/cli/test/migrate-config-keys.test.ts` + +**Interfaces:** + +- Produces: a migration with `id: "remove-dead-config-keys"`. + +- [ ] **Step 1: Write the failing test** + +Create `packages/cli/test/migrate-config-keys.test.ts`: + +```ts +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runMigrations } from "../src/update.ts"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +const CONFIG = `export default { + compatibilityDate: "2026-08-02", + frameworkBehaviour: 1, + functions: { legacyDefaultRuntime: "current" }, + compatibility: { legacyEmit: false, stringLayouts: false }, + observability: { sampleRate: 1 }, +}; +`; + +function project(): string { + const root = mkdtempSync(join(tmpdir(), "wrnexus-migrate-")); + roots.push(root); + mkdirSync(join(root, "app"), { recursive: true }); + writeFileSync(join(root, "wrnexus.config.ts"), CONFIG); + return root; +} + +test("the removed keys are deleted and the rest is kept", async () => { + const root = project(); + await runMigrations({ appRoot: root, from: "0.8.0", to: "0.9.0", dryRun: false }); + const config = readFileSync(join(root, "wrnexus.config.ts"), "utf8"); + + expect(config).not.toContain("compatibilityDate"); + expect(config).not.toContain("frameworkBehaviour"); + expect(config).not.toContain("legacyDefaultRuntime"); + expect(config).not.toContain("legacyEmit"); + expect(config).toContain("observability"); +}); + +test("running it twice changes nothing the second time", async () => { + const root = project(); + await runMigrations({ appRoot: root, from: "0.8.0", to: "0.9.0", dryRun: false }); + const once = readFileSync(join(root, "wrnexus.config.ts"), "utf8"); + await runMigrations({ appRoot: root, from: "0.8.0", to: "0.9.0", dryRun: false }); + + expect(readFileSync(join(root, "wrnexus.config.ts"), "utf8")).toBe(once); +}); + +test("a dry run writes nothing", async () => { + const root = project(); + await runMigrations({ appRoot: root, from: "0.8.0", to: "0.9.0", dryRun: true }); + + expect(readFileSync(join(root, "wrnexus.config.ts"), "utf8")).toBe(CONFIG); +}); +``` + +Use whatever entry point `update.ts` exports for running migrations; if the name differs from `runMigrations`, adapt the calls and keep the assertions identical. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test packages/cli/test/migrate-config-keys.test.ts` +Expected: FAIL — the keys survive. + +- [ ] **Step 3: Add the migration** + +Append to the migration list in `packages/cli/src/update.ts`: + +```ts + { + version: "0.9.0", + id: "remove-dead-config-keys", + description: "Delete compatibilityDate, frameworkBehaviour, functions, and compatibility", + apply(ctx) { + const file = join(ctx.appRoot, "wrnexus.config.ts"); + if (!existsSync(file)) return; + + const before = readFileSync(file, "utf8"); + // Each key is a whole property line or block; removing the line leaves + // valid TypeScript because these are always object members. + const after = before + .replace(/^\s*compatibilityDate:.*\n/m, "") + .replace(/^\s*frameworkBehaviour:.*\n/m, "") + .replace(/^\s*functions:\s*\{[^}]*\},?\s*\n/m, "") + .replace(/^\s*compatibility:\s*\{[^}]*\},?\s*\n/m, ""); + + if (after === before) return; + + ctx.report.changedAutomatically.push(`${file}: removed dead compatibility keys`); + if (!ctx.dryRun) writeFileSync(file, after, "utf8"); + }, + }, +``` + +- [ ] **Step 4: Run the tests** + +Run: `bun test packages/cli` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +bun run format +git add packages/cli +git commit -m "feat(cli): migrate away the dead config keys" +``` + +--- + +### Task 2: Move `ssr { api … }` / `client { api … }` into `apis { }` + +**Files:** + +- Create: `packages/cli/src/migrations/apis-block.ts` +- Modify: `packages/cli/src/update.ts` (register the migration) +- Test: `packages/cli/test/migrate-apis-block.test.ts` + +**Interfaces:** + +- Produces: `migrateApisBlock(source: string): { source: string; changed: boolean } | { skip: string }` — a pure function over `.wrn` text, so it is testable without a filesystem. `skip` carries the human-readable reason. + +Sectioned bodies carry across unchanged, because the payload is already bound to `data`. + +- [ ] **Step 1: Write the failing test** + +Create `packages/cli/test/migrate-apis-block.test.ts`: + +```ts +import { expect, test } from "bun:test"; +import { migrateApisBlock } from "../src/migrations/apis-block.ts"; + +const SOURCE = `page Search { + client { + api searchUsers POST /api/users { + request { body { name?: string } } + response { return data.users } + error { return [] } + } + } + + view {
x
} +} +`; + +test("a client api entry moves into an apis block", () => { + const result = migrateApisBlock(SOURCE) as { source: string; changed: boolean }; + + expect(result.changed).toBe(true); + expect(result.source).toContain("apis {"); + expect(result.source).toContain("searchUsers POST /api/users"); + expect(result.source).not.toContain("client {\n api"); +}); + +test("the sections survive unchanged", () => { + const result = migrateApisBlock(SOURCE) as { source: string }; + + expect(result.source).toContain("return data.users"); + expect(result.source).toContain("return []"); +}); + +test("running it on migrated source changes nothing", () => { + const once = (migrateApisBlock(SOURCE) as { source: string }).source; + const twice = migrateApisBlock(once) as { source: string; changed: boolean }; + + expect(twice.changed).toBe(false); + expect(twice.source).toBe(once); +}); + +test("a name declared in both modes is skipped with a reason", () => { + const clash = `page P { + ssr { api dup GET /api/a { response { return data } } } + client { api dup GET /api/a { response { return data } } } + view {
x
} +} +`; + const result = migrateApisBlock(clash) as { skip: string }; + + expect(result.skip).toContain("dup"); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test packages/cli/test/migrate-apis-block.test.ts` +Expected: FAIL — the module does not exist. + +- [ ] **Step 3: Implement the transform** + +Create `packages/cli/src/migrations/apis-block.ts`. Parse with `parse` from `@wrnexus/syntax` to find the entries and validate the file, collect every `api` entry from `ssr` / `client` blocks, detect duplicate names across modes and return `{ skip }` when found, then emit one `apis { }` block and delete the now-empty mode blocks. Re-emit through `formatWrn`. + +Detect already-migrated input by checking whether the source has an `apis` block and no mode data blocks; return `{ source, changed: false }`. + +- [ ] **Step 4: Register it** + +Add a `Migration` with `id: "move-api-blocks"` that walks `app/**/*.wrn`, calls `migrateApisBlock`, and routes the outcome: a change goes to `changedAutomatically`, a `skip` goes to `needsReview` with the file and reason, and a `parse` failure goes to `parseFailures` with the file left untouched. + +- [ ] **Step 5: Run the tests** + +Run: `bun test packages/cli` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +bun run format +git add packages/cli +git commit -m "feat(cli): migrate api entries into the apis block" +``` + +--- + +### Task 3: Move mode-scoped helpers into `functions { shared … }` + +**Files:** + +- Create: `packages/cli/src/migrations/mode-functions.ts` +- Modify: `packages/cli/src/update.ts` +- Test: `packages/cli/test/migrate-mode-functions.test.ts` + +**Interfaces:** + +- Produces: `migrateModeFunctions(source: string): { source: string; changed: boolean } | { skip: string }`. + +- [ ] **Step 1: Write the failing test** + +Create `packages/cli/test/migrate-mode-functions.test.ts`: + +```ts +import { expect, test } from "bun:test"; +import { migrateModeFunctions } from "../src/migrations/mode-functions.ts"; + +const SOURCE = `page Hello { + ssr { + functions { + function userNames(users) { + return users.map((user) => user.name).join(", ") + } + } + } + + view {
x
} +} +`; + +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 {
x
} +} +`; + const result = migrateModeFunctions(clash) as { skip: string }; + + expect(result.skip).toContain("userNames"); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test packages/cli/test/migrate-mode-functions.test.ts` +Expected: FAIL — the module does not exist. + +- [ ] **Step 3: Implement and register** + +Create the module following Task 2's shape: relocate each mode-scoped function into the page-level `functions { }` with the `shared` modifier, skipping the file with a reason when a name already exists there. Register a `Migration` with `id: "move-mode-functions"` that routes outcomes to the same three report buckets. + +- [ ] **Step 4: Run the tests** + +Run: `bun test packages/cli` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +bun run format +git add packages/cli +git commit -m "feat(cli): migrate mode-scoped helpers to shared functions" +``` + +--- + +### Task 4: Detect legacy bare-body blocks and report them — do not rewrite + +**Files:** + +- Create: `packages/cli/src/migrations/legacy-api-body.ts` +- Modify: `packages/cli/src/update.ts` +- Test: `packages/cli/test/migrate-legacy-api-body.test.ts` + +**Interfaces:** + +- Produces: `detectLegacyApiBodies(source: string): { name: string; freeIdentifiers: string[] }[]`. + +**This transform is deliberately manual, and the test pins that.** A legacy bare body is evaluated inside `with ($data ?? {})`, so it references payload fields as bare identifiers. Converting `return userNames(users)` needs `data.users` — but **nothing in the source distinguishes `users` (payload) from `userNames` (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 wrong. + +- [ ] **Step 1: Write the failing test** + +Create `packages/cli/test/migrate-legacy-api-body.test.ts`: + +```ts +import { expect, test } from "bun:test"; +import { detectLegacyApiBodies } from "../src/migrations/legacy-api-body.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([]); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test packages/cli/test/migrate-legacy-api-body.test.ts` +Expected: FAIL — the module does not exist. + +- [ ] **Step 3: Implement detection only** + +Create the module. Find `api` entries whose `sections` is absent (the bare-body form), and collect the free identifiers in the body — identifiers that are not declared locally and are not JavaScript globals. Return them. **Write no transform.** + +- [ ] **Step 4: Register a report-only migration** + +Add a `Migration` with `id: "report-legacy-api-bodies"` that pushes one `needsReview` entry per block, naming the file, the block, and the identifiers, and leaves the file byte-identical. Have `wrnexus update` print a short line explaining why this one is manual: the payload fields cannot be told apart from page helpers without knowing the route's response shape. + +- [ ] **Step 5: Write the byte-identical test** + +Add a test that runs the full migration over a fixture project containing a legacy bare body and asserts the file's contents are unchanged afterwards, and that the report names the block. **This is the most important test in the plan** — it pins that the migration does not attempt the rewrite. + +- [ ] **Step 6: Run the tests** + +Run: `bun test packages/cli` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +bun run format +git add packages/cli +git commit -m "feat(cli): report legacy api bodies for manual migration" +``` + +--- + +### Task 5: Exit code, output order, and the end-to-end run + +**Files:** + +- Modify: `packages/cli/src/update.ts` (the command's output and exit code) +- Test: `packages/cli/test/update-exit-code.test.ts` + +**Interfaces:** + +- Consumes: Tasks 1-4. + +- [ ] **Step 1: Write the failing test** + +Create `packages/cli/test/update-exit-code.test.ts` asserting that a project with a legacy bare body produces a non-zero exit, and a fully-migratable project produces zero. Use the same temp-project pattern as Task 1. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `bun test packages/cli/test/update-exit-code.test.ts` +Expected: FAIL — the command currently exits zero regardless. + +- [ ] **Step 3: Implement the output and exit code** + +Print, in order: what changed, what needs review and why, what failed to parse. Exit non-zero when `needsReview` or `parseFailures` is non-empty. + +- [ ] **Step 4: Migrate the example app with the command alone** + +```bash +bun run --cwd examples/basic-app wrnexus update +``` + +Expected: the `.wrn` pages are migrated by the tool, not by hand. **If the framework's own example cannot be migrated by the tool, the tool is not finished** — report that rather than editing the example manually. + +- [ ] **Step 5: Verify the migrated example** + +```bash +bun run --cwd examples/basic-app build +bun test +bun run typecheck +bun run check:production +``` + +- [ ] **Step 6: Commit** + +```bash +bun run format +git add -A +git commit -m "feat(cli): fail the update when a project needs manual review" +``` + +--- + +## Notes for the executor + +- **Never half-rewrite a file.** Parse first; on failure, record and move on. If any part of a file's transform cannot complete, skip the whole file and report it. +- **Idempotency is not optional.** Every transform detects already-migrated input. +- **Task 4 writes no transform.** If you find yourself building one, stop — the spec explains why a correct automatic answer does not exist.