Natural-language patches drift: off-by-one hunks, broken JSX, imports the model invented. Flip the contract — the LLM emits a structured transform intent; jscodeshift or ts-morph applies it. Mechanical precision stays in the codemod; the model only chooses what to rename, wrap, or migrate.
⚡ TL;DR: Define a JSON schema for intents (
rename_symbol,wrap_call,replace_import). Validate with Ajv; execute via ts-morph/jscodeshift; typecheck before commit. Reject freeform unified diffs for mechanical migrations. Pair with Structured Outputs for Codegen and LLM Output Validators.
Intent schema
{
"$id": "https://acme.dev/codemod-intent.json",
"type": "object",
"required": ["kind", "target"],
"properties": {
"kind": { "enum": ["rename_symbol", "replace_import", "wrap_call", "add_jsdoc"] },
"target": {
"type": "object",
"required": ["path", "exportName"],
"properties": {
"path": { "type": "string" },
"exportName": { "type": "string" }
}
},
"payload": { "type": "object" }
}
}
// intents/rename.ts
import { z } from "zod";
export const RenameIntent = z.object({
kind: z.literal("rename_symbol"),
target: z.object({ path: z.string(), exportName: z.string() }),
payload: z.object({ to: z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/) }),
});
Apply with ts-morph
import { Project } from "ts-morph";
import { RenameIntent } from "./intents/rename";
export function applyRename(raw: unknown) {
const intent = RenameIntent.parse(raw); // ❌ never apply unparsed model JSON
const project = new Project({ tsConfigFilePath: "tsconfig.json" });
const file = project.getSourceFileOrThrow(intent.target.path);
const exported = file.getExportedDeclarations().get(intent.target.exportName);
if (!exported?.length) throw new Error("symbol_not_found");
for (const decl of exported) {
// ts-morph rename updates references in the project graph
if ("rename" in decl) (decl as any).rename(intent.payload.to);
}
project.saveSync();
}
// jscodeshift example for import replacement
export default function transformer(file, api, opts) {
const j = api.jscodeshift;
const root = j(file.source);
root.find(j.ImportDeclaration, { source: { value: opts.from } })
.forEach((p) => { p.value.source.value = opts.to; });
return root.toSource();
}
Agent loop
- Model proposes intent JSON (structured output / tool).
- Ajv/Zod validate → deny unknown kinds.
- Codemod dry-run → diff.
tsc --noEmit+ unit tests on affected packages.- Human review for kinds marked
high_risk(public API renames).
Same gate philosophy as Cursor Agent Mode CI survival.
When freeform patches are still OK
Small non-mechanical edits (prose, one-off logic) can use hunks — but migrations across N files should be intents. Hybrid: model writes intent for 90% and a tiny freeform patch for the residual, each validated separately.
Closing checklist
✅ Dos
– ✅ Schema-validate every intent
– ✅ Apply via AST tools, not string replace
– ✅ Typecheck + tests after apply
– ✅ Dry-run diffs in the PR body
– ✅ Allowlist intent kinds in production agents
❌ Don’ts
– ❌ Don’t eval model-produced codemod source
– ❌ Don’t regex-rename symbols across the monorepo
– ❌ Don’t skip reference updates (exports only)
– ❌ Don’t accept intents that touch node_modules or generated/
Related reading
- Structured Outputs for Codegen: JSON Schemas That Actually Compile
- LLM Output Validators: Parse Trees Before Accepting Any Patches
- Cursor Agent Mode: Multi-File Refactors That Survive CI Gates
- Agentic Git Workflows: Atomic Commits From Noisy LLM Diffs
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
