diff --git a/packages/cli/src/update.ts b/packages/cli/src/update.ts index 0ce15e75..202f2e3e 100644 --- a/packages/cli/src/update.ts +++ b/packages/cli/src/update.ts @@ -554,6 +554,33 @@ function migrateImportedLayout( * migrations must preserve semantics and are protected by update backups. */ +/** Remove a `key: ` line from a config source, e.g. `compatibilityDate: "..."`. */ +function removeScalarConfigKey(source: string, key: string): string { + return source.replace(new RegExp(`^[ \\t]*${key}:.*\\n`, "m"), ""); +} + +/** + * Remove a `key: { ... }` object property from a config source, using balanced-brace + * scanning so a nested `{ ... }` inside the value can't truncate the removal early. + */ +function removeObjectConfigKey(source: string, key: string): string { + const match = new RegExp(`^[ \\t]*${key}\\s*:\\s*\\{`, "m").exec(source); + if (!match) return source; + const lineStart = match.index; + const openBrace = match.index + match[0].length - 1; + const closeBrace = findMatching(source, openBrace); + if (closeBrace < 0) return source; + + let end = closeBrace + 1; + while (source[end] === " " || source[end] === "\t") end++; + if (source[end] === ",") end++; + while (source[end] === " " || source[end] === "\t") end++; + if (source[end] === "\r") end++; + if (source[end] === "\n") end++; + + return source.slice(0, lineStart) + source.slice(end); +} + const MIGRATIONS: Migration[] = [ { version: "0.8.0", @@ -820,6 +847,27 @@ const MIGRATIONS: Migration[] = [ // no application source rewrite is required. }, }, + { + 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"); + let after = before; + after = removeScalarConfigKey(after, "compatibilityDate"); + after = removeScalarConfigKey(after, "frameworkBehaviour"); + after = removeObjectConfigKey(after, "functions"); + after = removeObjectConfigKey(after, "compatibility"); + + if (after === before) return; + + ctx.report.changedAutomatically.push(`${file}: removed dead compatibility keys`); + if (!ctx.dryRun) writeFileSync(file, after, "utf8"); + }, + }, ]; /** Release tooling uses this to require an explicit migration entry per version. */ diff --git a/packages/cli/test/migrate-config-keys.test.ts b/packages/cli/test/migrate-config-keys.test.ts new file mode 100644 index 00000000..b834e940 --- /dev/null +++ b/packages/cli/test/migrate-config-keys.test.ts @@ -0,0 +1,92 @@ +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 { updateApp } 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 }, +}; +`; + +const NESTED_CONFIG = `export default { + compatibilityDate: "2026-08-02", + frameworkBehaviour: 1, + functions: { legacyDefaultRuntime: "current", overrides: { a: { b: 1 } } }, + compatibility: { legacyEmit: false, nested: { deeper: { value: true } } }, + observability: { sampleRate: 1 }, +}; +`; + +function project(config = CONFIG): string { + const root = mkdtempSync(join(tmpdir(), "wrnexus-migrate-")); + roots.push(root); + mkdirSync(join(root, "app"), { recursive: true }); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ + name: "config-migrate-app", + dependencies: { "@wrnexus/core": "^0.8.0" }, + wrnexus: { version: "0.8.0" }, + }), + ); + writeFileSync(join(root, "wrnexus.config.ts"), config); + return root; +} + +test("the removed keys are deleted and the rest is kept", () => { + const root = project(); + updateApp(root, "0.9.0", 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", () => { + const root = project(); + updateApp(root, "0.9.0", false); + const once = readFileSync(join(root, "wrnexus.config.ts"), "utf8"); + updateApp(root, "0.9.0", false); + + expect(readFileSync(join(root, "wrnexus.config.ts"), "utf8")).toBe(once); +}); + +test("a dry run writes nothing", () => { + const root = project(); + updateApp(root, "0.9.0", true); + + expect(readFileSync(join(root, "wrnexus.config.ts"), "utf8")).toBe(CONFIG); +}); + +test("a nested object value under a removed key does not corrupt the file", () => { + const root = project(NESTED_CONFIG); + updateApp(root, "0.9.0", 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).not.toContain("overrides"); + expect(config).not.toContain("nested"); + expect(config).toContain("observability"); + + // The file must remain valid, balanced TypeScript: an equal number of + // opening and closing braces, and it must still be parseable as a module. + const opens = (config.match(/\{/g) ?? []).length; + const closes = (config.match(/\}/g) ?? []).length; + expect(opens).toBe(closes); +});