AST-Guided Edits: LLMs Propose Intents and Codemods Apply Them

AST-Guided Edits: LLMs Propose Intents and Codemods Apply Them

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

  1. Model proposes intent JSON (structured output / tool).
  2. Ajv/Zod validate → deny unknown kinds.
  3. Codemod dry-run → diff.
  4. tsc --noEmit + unit tests on affected packages.
  5. 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

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply