AST Codemod Refactoring Invariants: Making Large-Scale Automated Rewrites Provable and Reversible
A codemod can rewrite thousands of call sites at once, and just as reliably replicate the same bug. This article builds an executable invariant gate from node selection, semantic preservation, idempotency, format boundaries, and diff budgets.
Key Points
- Topic Focus: AI Agent、TypeScript、开源
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
When migrating an API call from an old signature to a new one, text replacement will mistakenly modify comments, strings, and same-named symbols; having a model rewrite file by file tends to miss callers and produce unrelated formatting changes. AST codemods can precisely select nodes, but "can be parsed and printed" does not equal behavior preservation. Incorrect symbol binding, default parameters, side-effect ordering, or comment loss will be mechanically replicated across the entire repository.
AST + LLM in Code Intelligence introduces how to find structure; this article only focuses on which invariants the transformation itself must satisfy, and how to make each run replayable, blockable, and undoable.
The commands in this article are fixed to run in a Linux/GNU CI container (Bash, GNU coreutils, Git); macOS/Windows runners must use the same container.
First Freeze the Transform's Input Boundary
A single codemod run is jointly determined by the baseline commit, file set, parser mode, language version, transform source, configuration, and printer version. All tools use the exact versions from the lockfile; CI installs from a clean checkout—do not use npx package@latest to run ad hoc. The first step only counts hits and does not write files:
BASE_SHA=$(git rev-parse HEAD)
mkdir -p .artifacts
pnpm install --frozen-lockfile
pnpm exec jscodeshift src \
--extensions=ts,tsx --parser=tsx \
--transform=tools/codemods/migrate-client.cjs \
--dry --print > .artifacts/codemod-preview.txt
The preview report lists the hit count, skip count, and reasons for each node type. If you expected to migrate 840 calls but only 600 hit, you cannot start writing; if the hit count suddenly jumps to 4,000, you cannot explain it away as the tool trying harder. Dynamic property access, conditional imports, and files with type errors go into a manual list.
Five Categories of Invariants Jointly Define Correctness
The selection invariant requires each modified node to bind to the target declaration, not merely to share the same name. TypeScript projects should use the type checker to confirm the symbol; with only a syntax tree, at least narrow it down via import source and scope. After modification, the count of old references to the target must drop to zero; any compatibility layer allowed to remain is listed separately on a whitelist.
The surface invariant constrains public exports, comment licenses, directives, encodings, and missed regions. Printers often rearrange quotes or whitespace, so you should restrict printing to only files that contain modified nodes, and set a maximum file count and a maximum line-count diff budget. Generated files, vendored files, and migration snapshots are excluded by default.
The semantic invariant comes from the business, not from the AST. For example, changing legacyCall(user, true) to client.call({ user, strict: true }) must prove that parameter evaluation order, exceptions, async awaiting, default values, and return types are unchanged. First use characterization tests to lock in the current behavior, then use Property Testing for AI-Generated Code to cover null values, Unicode, boundary numbers, and parameter combinations.
The type invariant requires that public declarations be comparable before and after the transform, that the whole-repo typecheck passes, and that you cannot trade new any, @ts-ignore, or widened return types for a green build. Even after the build passes, you must still run integration tests for affected callers.
Finally, the idempotency invariant: a second run must produce zero new diffs. It can catch problems such as matchers repeatedly wrapping nodes, imports being inserted repeatedly on each run, or unstable printers.
mkdir -p .artifacts
pnpm exec jscodeshift src --extensions=ts,tsx --parser=tsx \
--transform=tools/codemods/migrate-client.cjs
git diff --binary > .artifacts/pass-1.patch
pnpm exec jscodeshift src --extensions=ts,tsx --parser=tsx \
--transform=tools/codemods/migrate-client.cjs
git diff --binary > .artifacts/pass-2.patch
cmp .artifacts/pass-1.patch .artifacts/pass-2.patch
Small-Batch Execution Is More Auditable Than a One-Shot Whole-Repo Rewrite
Split batches by dependency layer or package, keeping each batch an independent build. First migrate leaf consumers, then migrate shared libraries; or first provide a compatibility API and then gradually migrate callers—the specific order is determined by the contract. Each batch outputs hit statistics, skip lists, file diffs, type checks, and test results, so reviewers can sample-check each form of the transformation.
pnpm exec tsc --noEmit
pnpm test -- --runInBand
git diff --check
git diff --stat "$BASE_SHA"
The command names should be replaced with scripts that already exist in the repo, and the exit code bound to the current HEAD. If a codemod modifies tests to fit an incorrect implementation, the validation gate must compare assertions against historical failure samples; ordinary refactoring guardrails can be combined with AI-Assisted Code Refactoring, but batch transformations must additionally check the hit set and idempotency.
After Failure, Handle by Category; Do Not Loosen the Matcher
A parse failure means a file was not covered; you cannot skip it and still claim full completion. If a symbol is ambiguous, escalate to manual handling; for type failures, first confirm whether the transform broke the signature; for test failures, reproduce with a minimal sample. To increase the pass rate, it is forbidden to delete comments, add ignore rules, downgrade the matcher from symbol to name, or expand the formatting scope.
Before rolling back, save the transform digest, baseline, batch file list, and patch. For unmerged batches, run git restore --source="$BASE_SHA" --staged --worktree -- <batch-paths> in an isolated worktree; for merged batches, use a reverse commit, and do not re-run an unvalidated "reverse codemod." After restoration, re-run typecheck and tests to confirm no leftover generated files or dependency locks remain.
The final deliverable is the transform source, pinned versions, hit and skip reports, evidence of two-run consistency, semantic tests, and the per-batch rollback SHA. Coding Agent Change Validation Gate is responsible for the overall merge decision; these codemod invariants ensure that large-scale automated rewrites are not mistaken for correct merely because the diff looks tidy.
Related
Related Content
Article
What Is Vibe Coding? Will It Change How Programmers Work?
Technical trends, engineering practices, and an indie developer perspective on Vibe Coding, AI, and independent development.
Article
From Copilot to Codex: AI Coding Tools Enter the Agent Era
Technical trends, engineering practices, and indie-developer perspectives around AI, AI Agents, and Vibe Coding.
Article
Why Does MCP Matter? Understanding the AI Tool Protocol
Technical trends, engineering practices, and an independent developer perspective on MCP and AI Agents.
Article
Core Architecture of AI Agents: Planning, Memory, Tools, and Execution
Technical trends, engineering practices, and independent developer perspectives around AI Agents and large language models.