ProvenPin Install

ts-morph code generation and AST codemods in memory: working versions (tested 2026-10-15)

Generate a typed TypeScript client module with ts-morph structures, type-check it with pre-emit diagnostics, and run codemods (cross-file rename, var to const, strip console.log) on an in-memory file system.

passed5 passed · 0 failed2026-10-15 20:10 UTC
Status
passed
Last passed
2026-10-15 20:10 UTC
Tests
5 / 5
Language
TypeScript
Runtime
node v22.23.3
pinned versions3
ts-morph@28.0.0@types/node@26.6.5tsx@4.23.15
run it
npm install --no-audit --no-fund --ignore-scripts=false
npm test
test environment
node v22.23.3
linux 6.17.0-1022-azure
x64
runner: github-actions 38799498972

The code

Exactly the files that passed. Your agent can pull them with get_recipe("ts-morph-codegen-ast-edit") (install), or see them on GitHub.

package.json
{
  "name": "ts-morph-codegen-ast-edit",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "node --import tsx --test test/*.test.ts"
  },
  "dependencies": {
    "ts-morph": "28.0.0"
  },
  "devDependencies": {
    "@types/node": "26.6.5",
    "tsx": "4.23.15"
  }
}
src/codemod.ts
import { Project, SyntaxKind, StructureKind, VariableDeclarationKind, type SourceFile } from "ts-morph";

export function inMemoryProject() {
  return new Project({ useInMemoryFileSystem: true, compilerOptions: { strict: true, target: 99, module: 99 } });
}

/** Generate a typed API module from a route table. */
export function generateClient(project: Project, routes: { name: string; method: "GET" | "POST"; path: string; returns: string }[]) {
  const sf = project.createSourceFile("/gen/client.ts", "", { overwrite: true });
  sf.addStatements("// generated by codemod.ts, do not edit");
  sf.addInterface({ name: "Course", isExported: true, properties: [{ name: "id", type: "string" }, { name: "hours", type: "number" }] });
  sf.addVariableStatement({
    declarationKind: VariableDeclarationKind.Const, isExported: true,
    declarations: [{ name: "BASE_URL", initializer: '"/api"' }],
  });
  for (const r of routes) {
    sf.addFunction({
      kind: StructureKind.Function, name: r.name, isExported: true, isAsync: true,
      parameters: r.method === "POST" ? [{ name: "body", type: "unknown" }] : [],
      returnType: `Promise<${r.returns}>`,
      statements: [
        `const res = await fetch(BASE_URL + ${JSON.stringify(r.path)}, { method: "${r.method}"${r.method === "POST" ? ", body: JSON.stringify(body)" : ""} });`,
        `return (await res.json()) as ${r.returns};`,
      ],
    });
  }
  sf.formatText({ indentSize: 2 });
  return sf;
}

/** Codemod: rename an exported function everywhere and convert `var` to `const`. */
export function renameAndModernize(sf: SourceFile, from: string, to: string) {
  sf.getFunctionOrThrow(from).rename(to);
  for (const vs of sf.getVariableStatements()) if (vs.getDeclarationKind() === VariableDeclarationKind.Var) vs.setDeclarationKind(VariableDeclarationKind.Const);
  return sf;
}

/** Remove console.log calls. */
export function stripConsoleLogs(sf: SourceFile) {
  const calls = sf.getDescendantsOfKind(SyntaxKind.CallExpression).filter((c) => c.getExpression().getText() === "console.log");
  for (const c of calls.reverse()) c.getParentIfKind(SyntaxKind.ExpressionStatement)?.remove();
  return calls.length;
}

export const typeErrors = (project: Project) =>
  project.getPreEmitDiagnostics().map((d) => (typeof d.getMessageText() === "string" ? d.getMessageText() : (d.getMessageText() as { getMessageText(): string }).getMessageText()) as string);
test/codemod.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { inMemoryProject, generateClient, renameAndModernize, stripConsoleLogs, typeErrors } from "../src/codemod.ts";

const routes = [
  { name: "listCourses", method: "GET" as const, path: "/courses", returns: "Course[]" },
  { name: "createCourse", method: "POST" as const, path: "/courses", returns: "Course" },
];

test("generates a typed client module", () => {
  const project = inMemoryProject();
  const sf = generateClient(project, routes);
  const text = sf.getFullText();
  assert.match(text, /export interface Course \{\n  id: string;\n  hours: number;\n\}/);
  assert.match(text, /export async function createCourse\(body: unknown\): Promise<Course> \{/);
  assert.deepEqual(sf.getFunctions().map((f) => f.getName()), ["listCourses", "createCourse"]);
});

test("generated code type-checks, broken code does not", () => {
  const project = inMemoryProject();
  project.createSourceFile("/lib.d.ts", "declare function fetch(u: string, i?: object): Promise<{ json(): Promise<unknown> }>;");
  generateClient(project, routes);
  assert.deepEqual(typeErrors(project), []);
  project.createSourceFile("/bad.ts", "const n: number = 'nope';");
  const bad = typeErrors(project);
  assert.equal(bad.length, 1);
  assert.match(bad[0], /not assignable to type 'number'/);
});

test("rename updates call sites and var becomes const", () => {
  const project = inMemoryProject();
  const sf = project.createSourceFile("/src/a.ts", "export function oldName(x: number) { return x * 2; }\nvar y = oldName(2);\nexport var z = oldName(y);\n");
  renameAndModernize(sf, "oldName", "double");
  assert.equal(sf.getFullText(), "export function double(x: number) { return x * 2; }\nconst y = double(2);\nexport const z = double(y);\n");
});

test("rename crosses files through imports", () => {
  const project = inMemoryProject();
  const a = project.createSourceFile("/src/a.ts", "export function oldName() { return 1; }\n");
  const b = project.createSourceFile("/src/b.ts", 'import { oldName } from "./a";\nexport const v = oldName();\n');
  renameAndModernize(a, "oldName", "one");
  assert.equal(b.getFullText(), 'import { one } from "./a";\nexport const v = one();\n');
});

test("strip console.log statements", () => {
  const project = inMemoryProject();
  const sf = project.createSourceFile("/c.ts", "function f() {\n  console.log('a');\n  return 1;\n}\nconsole.log(f());\nconsole.error('keep');\n");
  assert.equal(stripConsoleLogs(sf), 2);
  // ts-morph keeps the blank-line separation that followed the function
  assert.equal(sf.getFullText(), "function f() {\n  return 1;\n}\n\nconsole.error('keep');\n");
});

Connect to ProvenPin

Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.