480 lines
16 KiB
Markdown
480 lines
16 KiB
Markdown
# `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 { <main>x</main> }
|
|
}
|
|
`;
|
|
|
|
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 { <main>x</main> }
|
|
}
|
|
`;
|
|
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 { <main>x</main> }
|
|
}
|
|
`;
|
|
|
|
test("a mode helper becomes a shared function", () => {
|
|
const result = migrateModeFunctions(SOURCE) as { source: string; changed: boolean };
|
|
|
|
expect(result.changed).toBe(true);
|
|
expect(result.source).toContain("shared function userNames");
|
|
expect(result.source).not.toContain("ssr {");
|
|
});
|
|
|
|
test("running it again changes nothing", () => {
|
|
const once = (migrateModeFunctions(SOURCE) as { source: string }).source;
|
|
const twice = migrateModeFunctions(once) as { changed: boolean; source: string };
|
|
|
|
expect(twice.changed).toBe(false);
|
|
expect(twice.source).toBe(once);
|
|
});
|
|
|
|
test("a name that already exists at page level is skipped with a reason", () => {
|
|
const clash = `page P {
|
|
functions { shared function userNames() { return "" } }
|
|
ssr { functions { function userNames(users) { return "" } } }
|
|
view { <main>x</main> }
|
|
}
|
|
`;
|
|
const result = migrateModeFunctions(clash) as { skip: string };
|
|
|
|
expect(result.skip).toContain("userNames");
|
|
});
|
|
```
|
|
|
|
- [ ] **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 { <main>x</main> }
|
|
}
|
|
`;
|
|
|
|
test("a legacy bare body is detected with its free identifiers", () => {
|
|
const found = detectLegacyApiBodies(SOURCE);
|
|
|
|
expect(found).toHaveLength(1);
|
|
expect(found[0]!.name).toBe("ssrUsers");
|
|
expect(found[0]!.freeIdentifiers).toContain("users");
|
|
expect(found[0]!.freeIdentifiers).toContain("userNames");
|
|
});
|
|
|
|
test("a sectioned block is not reported", () => {
|
|
const sectioned = `page P {
|
|
apis { x GET /api/x { response { return data.users } } }
|
|
view { <main>x</main> }
|
|
}
|
|
`;
|
|
|
|
expect(detectLegacyApiBodies(sectioned)).toEqual([]);
|
|
});
|
|
```
|
|
|
|
- [ ] **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.
|