338 lines · 11.4 KB · TypeScript
RawDownload
#!/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.");}