#!/usr/bin/env bun/** * Codemod: replace `Schema.Class` and `Schema.TaggedClass` declarations with * `Schema.Struct` / `Schema.TaggedStruct` + type alias, and rewrite * `new ClassName(...)` call sites to `ClassName.make(...)`. * * Why: Effect 4.x `Schema.Class` does an `instanceof` check on encode, but * TypeScript's structural typing accepts plain objects that match the field * shape. The mismatch is the source of "Expected <Class>, got {...}" runtime * crashes when wire-decoded payloads reach storage encoders. gcanti's * recommendation: default to Struct. * * Usage: * bun run scripts/migrate-schema-class.ts # apply changes * bun run scripts/migrate-schema-class.ts --dry-run # preview only */import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";import { join, resolve } from "node:path";import ts from "typescript"; const DRY_RUN = process.argv.includes("--dry-run"); const repoRoot = resolve(import.meta.dir, "..");const IGNORE_DIRS = new Set([ "node_modules", ".git", ".reference", ".turbo", "dist", "build", ".local", ".changeset",]); // Files we intentionally leave alone — they contain `Schema.Class` strings as// lint-rule test fixtures, not real declarations.const SKIP_FILES = new Set([ resolve(repoRoot, "packages/core/sdk/src/oxlint-plugin-executor.test.ts"), resolve(repoRoot, "scripts/oxlint-plugin-executor/rules/no-schema-class.js"),]); interface ClassDeclInfo { readonly name: string; readonly kind: "Class" | "TaggedClass"; readonly identifier?: string; readonly fieldsText: string; readonly isExported: boolean; readonly fullStart: number; readonly end: number; readonly leadingDecorators?: string;} interface Edit { readonly start: number; readonly end: number; readonly replacement: string;} const collectTsFiles = (dir: string, out: string[] = []): string[] => { for (const entry of readdirSync(dir)) { if (IGNORE_DIRS.has(entry)) continue; const full = join(dir, entry); const stat = statSync(full); if (stat.isDirectory()) { collectTsFiles(full, out); continue; } if (!stat.isFile()) continue; if (!entry.endsWith(".ts") && !entry.endsWith(".tsx")) continue; if (entry.endsWith(".d.ts")) continue; if (SKIP_FILES.has(full)) continue; out.push(full); } return out;}; /** Match an extends clause shape: * Schema.Class<X>("ID")(FIELDS_EXPR) * or * Schema.TaggedClass<X>()("ID", FIELDS_EXPR) * Returns null if the clause doesn't match. */const parseSchemaClassExtends = ( expression: ts.LeftHandSideExpression,): { kind: "Class" | "TaggedClass"; identifier: string; fieldsText: string;} | null => { // outermost call: schemaCall(fieldsExpr) for Class, or schemaCall("Tag", fieldsExpr) for TaggedClass if (!ts.isCallExpression(expression)) return null; const fieldsCall = expression; const fieldsArgs = fieldsCall.arguments; // The callee is either: // Schema.Class<X>("ID") (a CallExpression) // Schema.TaggedClass<X>() (a CallExpression) const innerCall = fieldsCall.expression; if (!ts.isCallExpression(innerCall)) return null; // innerCall.expression should be `Schema.Class` or `Schema.TaggedClass` // (possibly with type arguments via PropertyAccessExpression). const innerCallee = innerCall.expression; if (!ts.isPropertyAccessExpression(innerCallee)) return null; if (!ts.isIdentifier(innerCallee.expression) || innerCallee.expression.text !== "Schema") { return null; } const methodName = innerCallee.name.text; if (methodName === "Class") { // Schema.Class<X>("ID")(FIELDS) if (innerCall.arguments.length !== 1) return null; const idArg = innerCall.arguments[0]; if (!ts.isStringLiteral(idArg) && !ts.isNoSubstitutionTemplateLiteral(idArg)) return null; if (fieldsArgs.length !== 1) return null; return { kind: "Class", identifier: idArg.text, fieldsText: fieldsArgs[0].getText(), }; } if (methodName === "TaggedClass") { // Schema.TaggedClass<X>()("Tag", FIELDS) if (innerCall.arguments.length !== 0) return null; if (fieldsArgs.length !== 2) return null; const tagArg = fieldsArgs[0]; if (!ts.isStringLiteral(tagArg) && !ts.isNoSubstitutionTemplateLiteral(tagArg)) return null; return { kind: "TaggedClass", identifier: tagArg.text, fieldsText: fieldsArgs[1].getText(), }; } return null;}; /** Build the replacement text for a migrated declaration. */const buildReplacement = (info: ClassDeclInfo): string => { const exportPrefix = info.isExported ? "export " : ""; const identifierAnnotation = info.kind === "Class" && info.identifier && info.identifier !== info.name ? `.annotate({ identifier: ${JSON.stringify(info.identifier)} })` : ""; if (info.kind === "Class") { return ( `${exportPrefix}const ${info.name} = Schema.Struct(${info.fieldsText})${identifierAnnotation};\n` + `${exportPrefix}type ${info.name} = typeof ${info.name}.Type;` ); } // TaggedClass return ( `${exportPrefix}const ${info.name} = Schema.TaggedStruct(${JSON.stringify(info.identifier)}, ${info.fieldsText});\n` + `${exportPrefix}type ${info.name} = typeof ${info.name}.Type;` );}; /** Find all migrate-able ClassDeclarations in a file. */const findClassDecls = (source: ts.SourceFile): ClassDeclInfo[] => { const out: ClassDeclInfo[] = []; const visit = (node: ts.Node) => { if (ts.isClassDeclaration(node) && node.heritageClauses && node.name) { const extendsClause = node.heritageClauses.find( (c) => c.token === ts.SyntaxKind.ExtendsKeyword, ); if (extendsClause && extendsClause.types.length === 1) { const parsed = parseSchemaClassExtends(extendsClause.types[0].expression); if (parsed) { // Only handle classes with empty bodies — anything with custom methods // needs human attention. if (node.members.length > 0) { console.warn( ` ⚠ ${node.name.text} has ${node.members.length} member(s); skipping (manual review)`, ); } else { const isExported = node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false; out.push({ name: node.name.text, kind: parsed.kind, identifier: parsed.identifier, fieldsText: parsed.fieldsText, isExported, fullStart: node.getStart(source), end: node.getEnd(), }); } } } } ts.forEachChild(node, visit); }; visit(source); return out;}; /** Collect a per-file map from local binding name → original imported name, * so `import { X as Y }` resolves Y back to X for the migrated-name check. */const collectImportAliases = (source: ts.SourceFile): Map<string, string> => { const aliases = new Map<string, string>(); for (const statement of source.statements) { if (!ts.isImportDeclaration(statement)) continue; const clause = statement.importClause; if (!clause || !clause.namedBindings) continue; if (!ts.isNamedImports(clause.namedBindings)) continue; for (const spec of clause.namedBindings.elements) { const localName = spec.name.text; const importedName = spec.propertyName?.text ?? localName; aliases.set(localName, importedName); } } return aliases;}; /** Find every `new <ClassName>(...)` call where ClassName (or its import * origin) is in the migrated set. Rewrites to `<LocalName>.make(...)` so * the local binding stays consistent. */const findConstructorCalls = ( source: ts.SourceFile, migrated: ReadonlySet<string>, aliases: ReadonlyMap<string, string>,): Edit[] => { const edits: Edit[] = []; const visit = (node: ts.Node) => { if (ts.isNewExpression(node) && ts.isIdentifier(node.expression)) { const localName = node.expression.text; const importedName = aliases.get(localName) ?? localName; if (migrated.has(importedName)) { const startOfNew = node.getStart(source); const expressionEnd = node.expression.getEnd(); // Replace `new ClassName` with `ClassName.make` using the local // binding name so the rewrite respects `import { X as Y }`. edits.push({ start: startOfNew, end: expressionEnd, replacement: `${localName}.make`, }); } } ts.forEachChild(node, visit); }; visit(source); return edits;}; const applyEdits = (text: string, edits: Edit[]): string => { // Apply in reverse order so positions stay valid. const sorted = [...edits].sort((a, b) => b.start - a.start); let out = text; for (const edit of sorted) { out = out.slice(0, edit.start) + edit.replacement + out.slice(edit.end); } return out;}; // ---------------------------------------------------------------------------// Pass 1 — discover every migrated class name (cross-file) so we can rewrite// `new X(...)` everywhere.// --------------------------------------------------------------------------- const files = collectTsFiles(repoRoot);console.log(`Scanning ${files.length} files...`); const fileClassInfo = new Map<string, ClassDeclInfo[]>();const migratedNames = new Set<string>(); for (const file of files) { const text = readFileSync(file, "utf8"); if (!text.includes("Schema.Class") && !text.includes("Schema.TaggedClass")) continue; const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); const decls = findClassDecls(source); if (decls.length > 0) { fileClassInfo.set(file, decls); for (const d of decls) migratedNames.add(d.name); }} console.log( `Found ${migratedNames.size} migrate-able class declarations across ${fileClassInfo.size} files.`,); // ---------------------------------------------------------------------------// Pass 2 — apply edits per file (declarations + constructor rewrites).// --------------------------------------------------------------------------- let filesChanged = 0;let declsMigrated = 0;let ctorsRewritten = 0; for (const file of files) { const text = readFileSync(file, "utf8"); if ( !text.includes("Schema.Class") && !text.includes("Schema.TaggedClass") && !text.includes("new ") ) { continue; } const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); const decls = fileClassInfo.get(file) ?? []; const aliases = collectImportAliases(source); const ctorEdits = findConstructorCalls(source, migratedNames, aliases); if (decls.length === 0 && ctorEdits.length === 0) continue; const declEdits: Edit[] = decls.map((d) => ({ start: d.fullStart, end: d.end, replacement: buildReplacement(d), })); const allEdits = [...declEdits, ...ctorEdits]; const newText = applyEdits(text, allEdits); if (newText === text) continue; filesChanged += 1; declsMigrated += decls.length; ctorsRewritten += ctorEdits.length; const rel = file.slice(repoRoot.length + 1); console.log( ` ${rel}: ${decls.length} decl${decls.length === 1 ? "" : "s"}, ${ctorEdits.length} ctor${ctorEdits.length === 1 ? "" : "s"}`, ); if (!DRY_RUN) { writeFileSync(file, newText); }} console.log("");console.log(`${DRY_RUN ? "[DRY RUN] Would change" : "Changed"} ${filesChanged} files:`);console.log(` ${declsMigrated} class declarations → Schema.Struct / TaggedStruct`);console.log(` ${ctorsRewritten} \`new X(...)\` → \`X.make(...)\``);if (DRY_RUN) { console.log("\nRun without --dry-run to apply.");}
#!/usr/bin/env bun
/**
* Codemod: replace `Schema.Class` and `Schema.TaggedClass` declarations with
* `Schema.Struct` / `Schema.TaggedStruct` + type alias, and rewrite
* `new ClassName(...)` call sites to `ClassName.make(...)`.
*
* Why: Effect 4.x `Schema.Class` does an `instanceof` check on encode, but
* TypeScript's structural typing accepts plain objects that match the field
* shape. The mismatch is the source of "Expected <Class>, got {...}" runtime
* crashes when wire-decoded payloads reach storage encoders. gcanti's
* recommendation: default to Struct.
*
* Usage:
* bun run scripts/migrate-schema-class.ts # apply changes
* bun run scripts/migrate-schema-class.ts --dry-run # preview only
*/
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import ts from "typescript";
const DRY_RUN = process.argv.includes("--dry-run");
const repoRoot = resolve(import.meta.dir, "..");
const IGNORE_DIRS = new Set([
"node_modules",
".git",
".reference",
".turbo",
"dist",
"build",
".local",
".changeset",
]);
// Files we intentionally leave alone — they contain `Schema.Class` strings as
// lint-rule test fixtures, not real declarations.
const SKIP_FILES = new Set([
resolve(repoRoot, "packages/core/sdk/src/oxlint-plugin-executor.test.ts"),
resolve(repoRoot, "scripts/oxlint-plugin-executor/rules/no-schema-class.js"),
]);
interface ClassDeclInfo {
readonly name: string;
readonly kind: "Class" | "TaggedClass";
readonly identifier?: string;
readonly fieldsText: string;
readonly isExported: boolean;
readonly fullStart: number;
readonly end: number;
readonly leadingDecorators?: string;
}
interface Edit {
readonly start: number;
readonly end: number;
readonly replacement: string;
}
const collectTsFiles = (dir: string, out: string[] = []): string[] => {
for (const entry of readdirSync(dir)) {
if (IGNORE_DIRS.has(entry)) continue;
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
collectTsFiles(full, out);
continue;
}
if (!stat.isFile()) continue;
if (!entry.endsWith(".ts") && !entry.endsWith(".tsx")) continue;
if (entry.endsWith(".d.ts")) continue;
if (SKIP_FILES.has(full)) continue;
out.push(full);
}
return out;
};
/** Match an extends clause shape:
* Schema.Class<X>("ID")(FIELDS_EXPR)
* or
* Schema.TaggedClass<X>()("ID", FIELDS_EXPR)
* Returns null if the clause doesn't match. */
const parseSchemaClassExtends = (
expression: ts.LeftHandSideExpression,
): {
kind: "Class" | "TaggedClass";
identifier: string;
fieldsText: string;
} | null => {
// outermost call: schemaCall(fieldsExpr) for Class, or schemaCall("Tag", fieldsExpr) for TaggedClass
if (!ts.isCallExpression(expression)) return null;
const fieldsCall = expression;
const fieldsArgs = fieldsCall.arguments;
// The callee is either:
// Schema.Class<X>("ID") (a CallExpression)
// Schema.TaggedClass<X>() (a CallExpression)
const innerCall = fieldsCall.expression;
if (!ts.isCallExpression(innerCall)) return null;
// innerCall.expression should be `Schema.Class` or `Schema.TaggedClass`
// (possibly with type arguments via PropertyAccessExpression).
const innerCallee = innerCall.expression;
if (!ts.isPropertyAccessExpression(innerCallee)) return null;
if (!ts.isIdentifier(innerCallee.expression) || innerCallee.expression.text !== "Schema") {
return null;
}
const methodName = innerCallee.name.text;
if (methodName === "Class") {
// Schema.Class<X>("ID")(FIELDS)
if (innerCall.arguments.length !== 1) return null;
const idArg = innerCall.arguments[0];
if (!ts.isStringLiteral(idArg) && !ts.isNoSubstitutionTemplateLiteral(idArg)) return null;
if (fieldsArgs.length !== 1) return null;
return {
kind: "Class",
identifier: idArg.text,
fieldsText: fieldsArgs[0].getText(),
};
}
if (methodName === "TaggedClass") {
// Schema.TaggedClass<X>()("Tag", FIELDS)
if (innerCall.arguments.length !== 0) return null;
if (fieldsArgs.length !== 2) return null;
const tagArg = fieldsArgs[0];
if (!ts.isStringLiteral(tagArg) && !ts.isNoSubstitutionTemplateLiteral(tagArg)) return null;
return {
kind: "TaggedClass",
identifier: tagArg.text,
fieldsText: fieldsArgs[1].getText(),
};
}
return null;
};
/** Build the replacement text for a migrated declaration. */
const buildReplacement = (info: ClassDeclInfo): string => {
const exportPrefix = info.isExported ? "export " : "";
const identifierAnnotation =
info.kind === "Class" && info.identifier && info.identifier !== info.name
? `.annotate({ identifier: ${JSON.stringify(info.identifier)} })`
: "";
if (info.kind === "Class") {
return (
`${exportPrefix}const ${info.name} = Schema.Struct(${info.fieldsText})${identifierAnnotation};\n` +
`${exportPrefix}type ${info.name} = typeof ${info.name}.Type;`
);
}
// TaggedClass
return (
`${exportPrefix}const ${info.name} = Schema.TaggedStruct(${JSON.stringify(info.identifier)}, ${info.fieldsText});\n` +
`${exportPrefix}type ${info.name} = typeof ${info.name}.Type;`
);
};
/** Find all migrate-able ClassDeclarations in a file. */
const findClassDecls = (source: ts.SourceFile): ClassDeclInfo[] => {
const out: ClassDeclInfo[] = [];
const visit = (node: ts.Node) => {
if (ts.isClassDeclaration(node) && node.heritageClauses && node.name) {
const extendsClause = node.heritageClauses.find(
(c) => c.token === ts.SyntaxKind.ExtendsKeyword,
);
if (extendsClause && extendsClause.types.length === 1) {
const parsed = parseSchemaClassExtends(extendsClause.types[0].expression);
if (parsed) {
// Only handle classes with empty bodies — anything with custom methods
// needs human attention.
if (node.members.length > 0) {
console.warn(
` ⚠ ${node.name.text} has ${node.members.length} member(s); skipping (manual review)`,
);
} else {
const isExported =
node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
out.push({
name: node.name.text,
kind: parsed.kind,
identifier: parsed.identifier,
fieldsText: parsed.fieldsText,
isExported,
fullStart: node.getStart(source),
end: node.getEnd(),
});
}
}
}
}
ts.forEachChild(node, visit);
};
visit(source);
return out;
};
/** Collect a per-file map from local binding name → original imported name,
* so `import { X as Y }` resolves Y back to X for the migrated-name check. */
const collectImportAliases = (source: ts.SourceFile): Map<string, string> => {
const aliases = new Map<string, string>();
for (const statement of source.statements) {
if (!ts.isImportDeclaration(statement)) continue;
const clause = statement.importClause;
if (!clause || !clause.namedBindings) continue;
if (!ts.isNamedImports(clause.namedBindings)) continue;
for (const spec of clause.namedBindings.elements) {
const localName = spec.name.text;
const importedName = spec.propertyName?.text ?? localName;
aliases.set(localName, importedName);
}
}
return aliases;
};
/** Find every `new <ClassName>(...)` call where ClassName (or its import
* origin) is in the migrated set. Rewrites to `<LocalName>.make(...)` so
* the local binding stays consistent. */
const findConstructorCalls = (
source: ts.SourceFile,
migrated: ReadonlySet<string>,
aliases: ReadonlyMap<string, string>,
): Edit[] => {
const edits: Edit[] = [];
const visit = (node: ts.Node) => {
if (ts.isNewExpression(node) && ts.isIdentifier(node.expression)) {
const localName = node.expression.text;
const importedName = aliases.get(localName) ?? localName;
if (migrated.has(importedName)) {
const startOfNew = node.getStart(source);
const expressionEnd = node.expression.getEnd();
// Replace `new ClassName` with `ClassName.make` using the local
// binding name so the rewrite respects `import { X as Y }`.
edits.push({
start: startOfNew,
end: expressionEnd,
replacement: `${localName}.make`,
});
}
}
ts.forEachChild(node, visit);
};
visit(source);
return edits;
};
const applyEdits = (text: string, edits: Edit[]): string => {
// Apply in reverse order so positions stay valid.
const sorted = [...edits].sort((a, b) => b.start - a.start);
let out = text;
for (const edit of sorted) {
out = out.slice(0, edit.start) + edit.replacement + out.slice(edit.end);
}
return out;
};
// ---------------------------------------------------------------------------
// Pass 1 — discover every migrated class name (cross-file) so we can rewrite
// `new X(...)` everywhere.
// ---------------------------------------------------------------------------
const files = collectTsFiles(repoRoot);
console.log(`Scanning ${files.length} files...`);
const fileClassInfo = new Map<string, ClassDeclInfo[]>();
const migratedNames = new Set<string>();
for (const file of files) {
const text = readFileSync(file, "utf8");
if (!text.includes("Schema.Class") && !text.includes("Schema.TaggedClass")) continue;
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
const decls = findClassDecls(source);
if (decls.length > 0) {
fileClassInfo.set(file, decls);
for (const d of decls) migratedNames.add(d.name);
}
}
console.log(
`Found ${migratedNames.size} migrate-able class declarations across ${fileClassInfo.size} files.`,
);
// ---------------------------------------------------------------------------
// Pass 2 — apply edits per file (declarations + constructor rewrites).
// ---------------------------------------------------------------------------
let filesChanged = 0;
let declsMigrated = 0;
let ctorsRewritten = 0;
for (const file of files) {
const text = readFileSync(file, "utf8");
if (
!text.includes("Schema.Class") &&
!text.includes("Schema.TaggedClass") &&
!text.includes("new ")
) {
continue;
}
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
const decls = fileClassInfo.get(file) ?? [];
const aliases = collectImportAliases(source);
const ctorEdits = findConstructorCalls(source, migratedNames, aliases);
if (decls.length === 0 && ctorEdits.length === 0) continue;
const declEdits: Edit[] = decls.map((d) => ({
start: d.fullStart,
end: d.end,
replacement: buildReplacement(d),
}));
const allEdits = [...declEdits, ...ctorEdits];
const newText = applyEdits(text, allEdits);
if (newText === text) continue;
filesChanged += 1;
declsMigrated += decls.length;
ctorsRewritten += ctorEdits.length;
const rel = file.slice(repoRoot.length + 1);
console.log(
` ${rel}: ${decls.length} decl${decls.length === 1 ? "" : "s"}, ${ctorEdits.length} ctor${ctorEdits.length === 1 ? "" : "s"}`,
);
if (!DRY_RUN) {
writeFileSync(file, newText);
}
}
console.log("");
console.log(`${DRY_RUN ? "[DRY RUN] Would change" : "Changed"} ${filesChanged} files:`);
console.log(` ${declsMigrated} class declarations → Schema.Struct / TaggedStruct`);
console.log(` ${ctorsRewritten} \`new X(...)\` → \`X.make(...)\``);
if (DRY_RUN) {
console.log("\nRun without --dry-run to apply.");
}