Files
WRNexusJS/docs/superpowers/plans/2026-08-19-update-migration.md
ClintchizandClaude Opus 5 7a1b4e5b33 fix(cli): guard the one migration write path that skips parse validation
mode-functions writes without a parse check when a mode wrapper survives
holding api entries, since that intermediate state is unparseable until
move-api-blocks runs later in the same pass. Brace balance is the invariant
a bad splice offset would break, so check that instead; nothing downstream
could tell a corrupted wrapper from an untouched one.

Also records that Task 5's example-app migration ran against an already-
migrated target and so did not prove end-to-end behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:24:19 +05:30

17 KiB

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:

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:

  {
    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
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:

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
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:

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
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:

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
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
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
bun run --cwd examples/basic-app build
bun test
bun run typecheck
bun run check:production
  • Step 6: Commit
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.

Post-execution note (2026-08-20)

Step 4 of Task 5 did not prove what it was written to prove. By the time it ran, examples/basic-app had already been moved to the current syntax by hand in commit 890d6106, so wrnexus update migrated a no-op target: 0 changed, 0 needing review, 0 parse failures. The framework's own example therefore does NOT demonstrate the tool against real legacy syntax.

The guarantee instead rests on fixture-based tests that drive the real updateApp / runUpdate entry points over projects containing genuine ssr {} / client {} source -- including the mixed-content page that only converges because move-mode-functions is registered before move-api-blocks. That is adequate coverage, but it is a weaker kind of evidence than the plan intended, and it is recorded here rather than quietly counted as a pass.