1#!/usr/bin/env bun
2/**
3 * Codemod: migrate the test suite from vitest to the alchemy-test harness.
4 *
5 * Rewrites, in every `packages/alchemy/test/**\/*.ts(x)` file:
6 *
7 * 1. `from "@/Test/Vitest"` -> `from "@/Test/Alchemy"`
8 * 2. `from "@effect/vitest"` -> `from "alchemy-test"`
9 * 3. `from "vitest"` -> `from "alchemy-test"`
10 * 4. `from "node:test"` -> `from "alchemy-test"` (stray auto-imports;
11 * node:test's describe/it throw outside the node/bun test runner)
12 * 5. merges duplicate `import { ... } from "alchemy-test"` statements that
13 * steps 2-4 can produce in the same file.
14 *
15 * Idempotent: re-running on an already-migrated (or partially migrated) tree
16 * is a no-op, so it can be re-applied on other branches after merging.
17 *
18 * Usage:
19 * bun scripts/codemod-alchemy-test.ts [--dry-run]
20 */
21import { Glob } from "bun";
22import path from "node:path";
23
24const ROOT = path.resolve(import.meta.dirname, "..");
25const TEST_DIR = path.join(ROOT, "packages", "alchemy", "test");
26const DRY_RUN = process.argv.includes("--dry-run");
27
28/** Rewrite a module specifier wherever it appears in import/export syntax. */
29const rewriteSpecifier = (source: string, from: string, to: string): string => {
30 const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
31 // Matches: from "x" | from 'x' | import("x") | import "x" | require("x")
32 return source
33 .replace(
34 new RegExp(`(from\\s*)(["'])${escaped}\\2`, "g"),
35 (_m, prefix, quote) => `${prefix}${quote}${to}${quote}`,
36 )
37 .replace(
38 new RegExp(`(import\\s*\\(\\s*)(["'])${escaped}\\2`, "g"),
39 (_m, prefix, quote) => `${prefix}${quote}${to}${quote}`,
40 )
41 .replace(
42 new RegExp(`(^|\\n)(import\\s+)(["'])${escaped}\\3`, "g"),
43 (_m, lead, prefix, quote) => `${lead}${prefix}${quote}${to}${quote}`,
44 );
45};
46
47/**
48 * Merge multiple `import { a } from "alchemy-test"` statements into one.
49 * Keeps `import type { ... }` statements separate from value imports.
50 */
51const mergeDuplicateImports = (source: string, specifier: string): string => {
52 const pattern = new RegExp(
53 `^import\\s+(type\\s+)?\\{([^}]*)\\}\\s+from\\s+["']${specifier}["'];?\\s*$`,
54 "gm",
55 );
56 const matches = [...source.matchAll(pattern)];
57 const merge = (typeOnly: boolean): void => {
58 const group = matches.filter((m) => Boolean(m[1]) === typeOnly);
59 if (group.length < 2) return;
60 const names = new Set<string>();
61 for (const m of group) {
62 for (const name of m[2]!.split(",")) {
63 const trimmed = name.trim();
64 if (trimmed !== "") names.add(trimmed);
65 }
66 }
67 const sorted = [...names].sort((a, b) =>
68 a.replace(/^type\s+/, "").localeCompare(b.replace(/^type\s+/, "")),
69 );
70 const merged = `import ${typeOnly ? "type " : ""}{ ${sorted.join(", ")} } from "${specifier}";`;
71 let first = true;
72 for (const m of group) {
73 if (first) {
74 source = source.replace(m[0]!, merged);
75 first = false;
76 } else {
77 // Remove the statement and its trailing newline.
78 source = source.replace(new RegExp(`${escapeRegExp(m[0]!)}\\n?`), "");
79 }
80 }
81 };
82 merge(false);
83 merge(true);
84 return source;
85};
86
87const escapeRegExp = (text: string): string =>
88 text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
89
90const migrate = (source: string): string => {
91 let out = source;
92 out = rewriteSpecifier(out, "@/Test/Vitest", "@/Test/Alchemy");
93 out = rewriteSpecifier(out, "@/Test/Vitest.ts", "@/Test/Alchemy.ts");
94 out = rewriteSpecifier(out, "@effect/vitest", "alchemy-test");
95 out = rewriteSpecifier(out, "vitest", "alchemy-test");
96 out = rewriteSpecifier(out, "node:test", "alchemy-test");
97 out = mergeDuplicateImports(out, "alchemy-test");
98 return out;
99};
100
101const glob = new Glob("**/*.{ts,tsx}");
102let changed = 0;
103let scanned = 0;
104
105for await (const relative of glob.scan({ cwd: TEST_DIR })) {
106 const file = path.join(TEST_DIR, relative);
107 scanned++;
108 const source = await Bun.file(file).text();
109 const migrated = migrate(source);
110 if (migrated !== source) {
111 changed++;
112 if (DRY_RUN) {
113 console.log(`[dry-run] would update ${path.relative(ROOT, file)}`);
114 } else {
115 await Bun.write(file, migrated);
116 console.log(`updated ${path.relative(ROOT, file)}`);
117 }
118 }
119}
120
121console.log(
122 `${DRY_RUN ? "[dry-run] " : ""}codemod complete: ${changed}/${scanned} files ${DRY_RUN ? "would be " : ""}updated`,
123);