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
~/.cursor/mcp.json
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }.vscode/mcp.json
{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }~/.codeium/windsurf/mcp_config.json
{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }MCP settings
{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.