Codemods skill for Claude Code
Hundreds of call sites changed, tested, and accounted for. Claude inventories every shape of the target pattern, runs official codemods first, then writes a narrow transform in the right tool with before, after and must-not-change fixtures. Dry-run diffs are reviewed on a sample covering all shapes, changes land in reviewable batches with the full test gate, a residue scan explains every leftover site, and a lint rule stops the old pattern returning.
- name
codemods - size144 lines · ≈1,750 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Performs large-scale automated refactors with codemods instead of hand edits, choosing between official framework codemods, ast-grep rewrite rules, jscodeshift transforms, LibCST, OpenRewrite recipes for Java and Kotlin, and comby, with fixture tests, dry runs, a residue scan for cases the transform skipped, batched commits sized for review, and a lint rule that stops the old pattern from coming back. Use when the user wants to rename or replace an API across many files, migrate a library or framework version, enforce a new pattern repo-wide, or apply the same change to hundreds of call sites. Try asking
- Rename fetchUser to getUser everywhere, including aliased imports and mocks
- Replace all obj.hasOwnProperty(k) calls with Object.hasOwn across the repo
- Migrate our Java tests from assertEquals to AssertJ assertThat automatically
Use it when
- The same change must be applied to dozens or hundreds of sites
- A framework or library upgrade needs mechanical code changes
- A new pattern should be enforced across the whole repo
- A previous search-and-replace left broken or missed cases
Skip it when
- You need to find usages rather than rewrite them — use code-search instead
- The work is a version upgrade decision with changelog review — use dependency-upgrades instead
How to check the result
Fixtures pass including must-not-change cases, the applied batches build and pass tests, rerunning the codemod produces no diff, and every residual match is explained.
The common mistake it prevents
Using regex search-and-replace on code, which breaks nested calls, comments and strings, and leaves no count of the sites it silently missed.
A codemod run is done when the transform was tested on fixtures covering the tricky shapes, the dry-run diff was reviewed on a sample, the applied change builds and passes tests, a residue search shows what the codemod deliberately left for hand-fixing, and a lint rule prevents regressions. A codemod that “mostly worked” without a residue count is not done.
When a codemod pays off
| Situation | Approach |
|---|---|
| Under ~15 sites, each slightly different | Hand edit (with code-search to find them all) |
| 15+ sites with a regular shape | Codemod |
| Framework/library upgrade with an official codemod | Official codemod first, then a custom one for the rest |
| Semantics differ per site (needs judgment) | Codemod the mechanical part, flag the rest with a TODO(codemod) comment or list |
| Text-only change in non-code files | sed/perl -pi with a reviewed diff is fine |
Pick the tool
| Tool | Best for | Notes |
|---|---|---|
Official codemods (npx @next/codemod, ng update, pyupgrade, django-upgrade, ruff --fix UP rules, go fix, cargo fix --edition, OpenRewrite migration recipes) | Version upgrades | Maintained by people who know the edge cases |
| ast-grep | Pattern-to-pattern rewrites in most languages; one-liners and YAML rules | Fast, readable; default choice for simple shapes |
| jscodeshift | JS/TS transforms needing logic: scopes, imports, conditional rewrites | Full AST access; preserves formatting via recast |
| LibCST | Python with logic, format-preserving | ast-based tools lose comments; LibCST does not |
| OpenRewrite | Java/Kotlin/Spring/Gradle/Maven, type-aware | Recipes for framework migrations; dry run produces a patch |
| comby | Languages without good AST tooling, templates | Balanced-delimiter matching, not type-aware |
| IDE refactor (rename symbol, move) | Renames and moves in typed languages | Type-aware and safest for pure renames |
Ready-to-adapt recipes for each tool (ast-grep one-liner and rule file, jscodeshift import rename, LibCST call rename, OpenRewrite recipe, comby) are in references/codemod-recipes.md. Read it when writing the transform.
Workflow
- [ ] 1. Find every site; classify shapes (code-search); count them
- [ ] 2. Check for an official codemod; run it first if one exists
- [ ] 3. Write fixtures: before/after pairs for each shape + must-not-change cases
- [ ] 4. Write the transform; iterate until fixtures pass
- [ ] 5. Dry run on the repo; review a sample diff (10-20 files, all shapes)
- [ ] 6. Apply in batches; format; build + typecheck + tests per batch
- [ ] 7. Residue scan: count what is left and why; hand-fix or list
- [ ] 8. Add a lint rule banning the old pattern; commit the codemod script
1. Inventory shapes
Before writing anything, list the forms the target takes:
rg -n -w 'fetchUser' -t ts src | wc -l
rg -n 'import .*fetchUser' -t ts src | sed 's/:.*import/: import/' | sort | uniq -c | sort -rn | head
ast-grep run -l ts -p 'fetchUser($$$)' src --json | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))'
Typical shapes: plain import, aliased import, namespace import (api.fetchUser),
re-export, dynamic import, passed as a value (map(fetchUser)), mocked in tests
(jest.mock, vi.mock), mentioned in strings or docs.
3–4. Fixtures and transform
Fixtures are the spec. For each shape a before/after pair, plus negative cases that must
not change (same name from a different module, a local variable shadowing it, a string).
Keep fixtures in codemods/__testfixtures__/ next to the transform and run them in CI if
the codemod will be reused (for example by downstream consumers of a library).
Transform rules:
- Match narrowly (module source, receiver type, argument count) and skip anything ambiguous rather than guessing; ambiguous sites go to the residue list.
- Return the source unchanged (or
undefinedin jscodeshift) when nothing matched so untouched files do not get reformatted. - Idempotent: running twice must produce no further changes. Test it.
5. Dry run and review
ast-grep scan -r codemods/rule.yml src # diff preview, no writes
npx jscodeshift -t codemods/t.js --parser=tsx --extensions=ts,tsx --dry --print src/a.ts
mvn -U org.openrewrite.maven:rewrite-maven-plugin:dryRun ... # writes target/rewrite/rewrite.patch
Review a sample that covers every shape, not the first 10 files alphabetically. Start
from a clean working tree so git diff shows only the codemod.
6. Apply in batches
- Batch by directory, package, or owner (CODEOWNERS), sized so each PR reviews in under ~30 minutes (roughly 300–800 changed lines of mechanical diff).
- After each batch: run the project formatter, then build, typecheck, lint, tests.
- Commit the mechanical change separately from hand fixes:
refactor: rename fetchUser to getUser (codemod)thenfix: hand-migrate dynamic fetchUser usages. Reviewers can skim the first and focus on the second. - Add the codemod commit to
.git-blame-ignore-revsso blame stays useful.
7. Residue scan
rg -n -w 'fetchUser' src # what is left?
rg -n 'TODO\(codemod\)' src | wc -l # flagged for humans
Every remaining hit is explained: intentionally skipped shape, false positive, or hand-fixed. Report the counts.
8. Prevent regressions
Add a rule that fails on the old pattern: ESLint no-restricted-imports /
no-restricted-syntax, an ast-grep scan rule in CI, a Ruff/flake8 banned-API rule, or
an OpenRewrite/ErrorProne check. Commit the codemod under codemods/ with a README line
so downstream repos or late branches can rerun it.
Gotchas
- Formatting churn: a transform that reprints whole files buries the real change. Use format-preserving tools and run the project formatter after.
- Long-lived branches will conflict. Announce the window, merge batches quickly, and give branch owners the codemod to rerun after rebasing.
- Regex-based replacements on code break on nesting, comments, and strings. Use a structural tool unless the change is truly textual.
- Type-dependent rewrites (only calls on
Fooinstances) need type information: OpenRewrite, an IDE refactor, or ts-morph with the type checker, not plain syntax matching. - Generated files: exclude them and regenerate instead of transforming.
- A codemod across public APIs is a breaking change for consumers; ship a deprecation alias first.
Output
**Change:** fetchUser -> getUser across web and server (412 sites, 6 shapes)
**Tooling:** jscodeshift transform (codemods/rename-fetch-user.js), 14 fixtures incl. 4 must-not-change
**Applied:** 5 PRs by package, each green (build, typecheck, 2,310 tests)
**Residue:** 9 sites — 6 dynamic `api[name]` calls hand-migrated, 3 in vendored code left on purpose
**Guardrail:** no-restricted-imports bans fetchUser from "@/api"; codemod commit in .git-blame-ignore-revs Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/codemod-recipes.md
# Codemod recipes
Working starting points for each tool. Run every recipe on a fixture first, then on one
directory, then everywhere.
## ast-grep: one-off rewrite
```bash
ast-grep run -l ts -p 'console.log($$$ARGS)' -r 'logger.debug($$$ARGS)' src # preview diff
ast-grep run -l ts -p 'console.log($$$ARGS)' -r 'logger.debug($$$ARGS)' src --update-all # apply
ast-grep run -l ts -p 'console.log($$$ARGS)' -r 'logger.debug($$$ARGS)' src --interactive # per match
```
Install: `npm i -g @ast-grep/cli` (binary `ast-grep`; `sg` alias on some installs) or
`brew install ast-grep`.
## ast-grep: rule file with constraints
`rules/prefer-object-hasown.yml`:
```yaml
id: prefer-object-hasown
language: TypeScript
rule:
pattern: $OBJ.hasOwnProperty($KEY)
not:
inside:
kind: call_expression
pattern: Object.prototype.hasOwnProperty.call($$$)
fix: Object.hasOwn($OBJ, $KEY)
message: Use Object.hasOwn instead of obj.hasOwnProperty
severity: warning
```
```bash
ast-grep scan -r rules/prefer-object-hasown.yml src # report + diff preview
ast-grep scan -r rules/prefer-object-hasown.yml src --update-all # apply
```
Metavariables: `$X` one node, `$$$` zero or more nodes, `$_` match without capturing.
Metavariables do not match inside string literal contents; for strings use `regex:` on a
captured node with `constraints`. Keep the rule in the repo afterwards as a lint rule to
stop the old pattern returning (`ast-grep scan` in CI with an `sgconfig.yml`).
## jscodeshift: rename an import and its call sites
`codemods/rename-fetch-user.js`:
```js
// fetchUser -> getUser for imports from "@/api"; aliased imports keep their local name.
module.exports = function transform(file, api) {
const j = api.jscodeshift;
const root = j(file.source);
let changed = false;
let renameCalls = false;
root
.find(j.ImportDeclaration, { source: { value: "@/api" } })
.find(j.ImportSpecifier, { imported: { name: "fetchUser" } })
.forEach((path) => {
const aliased = path.node.local && path.node.local.name !== "fetchUser";
path.node.imported = j.identifier("getUser");
if (!aliased) {
path.node.local = j.identifier("getUser");
renameCalls = true;
}
changed = true;
});
if (renameCalls) {
root
.find(j.CallExpression, { callee: { type: "Identifier", name: "fetchUser" } })
.forEach((path) => {
path.node.callee = j.identifier("getUser");
});
}
return changed ? root.toSource({ quote: "double" }) : undefined; // undefined = skip file
};
```
```bash
npx jscodeshift -t codemods/rename-fetch-user.js --parser=tsx --extensions=ts,tsx --dry --print src/one-file.ts
npx jscodeshift -t codemods/rename-fetch-user.js --parser=tsx --extensions=ts,tsx src
```
Notes: `--parser=tsx` handles both TS and TSX; returning `undefined` reports the file as
skipped; recast preserves untouched formatting but run the project formatter afterwards.
Write fixture tests with `jscodeshift/dist/testUtils` (`defineInlineTest`) for anything
beyond a trivial rename.
## Python: LibCST (format-preserving)
```python
# codemods/rename_call.py -- python3 -m pip install libcst
import sys, pathlib, libcst as cst
class Rename(cst.CSTTransformer):
def leave_Call(self, orig, updated):
f = updated.func
if isinstance(f, cst.Attribute) and f.attr.value == "fetch_user" \
and isinstance(f.value, cst.Name) and f.value.value == "api":
return updated.with_changes(func=f.with_changes(attr=cst.Name("get_user")))
return updated
for path in map(pathlib.Path, sys.argv[1:]):
src = path.read_text()
out = cst.parse_module(src).visit(Rename()).code
if out != src:
path.write_text(out); print("changed", path)
```
`python3 codemods/rename_call.py $(git ls-files '*.py')`. For simple Python renames,
`ast-grep -l py` is often enough.
## OpenRewrite (Java/Kotlin, Maven or Gradle)
Run a published recipe without changing the build file (Maven):
```bash
mvn -U org.openrewrite.maven:rewrite-maven-plugin:dryRun \
-Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-spring:RELEASE \
-Drewrite.activeRecipes=org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_3
# review target/rewrite/rewrite.patch, then:
mvn -U org.openrewrite.maven:rewrite-maven-plugin:run \
-Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-spring:RELEASE \
-Drewrite.activeRecipes=org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_3
```
Custom declarative recipe in `rewrite.yml` at the project root:
```yaml
type: specs.openrewrite.org/v1beta/recipe
name: com.acme.RenameLegacyClient
displayName: Rename LegacyClient to HttpClient
recipeList:
- org.openrewrite.java.ChangeType:
oldFullyQualifiedTypeName: com.acme.net.LegacyClient
newFullyQualifiedTypeName: com.acme.net.HttpClient
- org.openrewrite.java.ChangeMethodName:
methodPattern: com.acme.net.HttpClient send(..)
newMethodName: execute
```
Activate with `-Drewrite.activeRecipes=com.acme.RenameLegacyClient`. Gradle uses the
`org.openrewrite.rewrite` plugin with `rewriteDryRun` / `rewriteRun` tasks. Check recipe
names in the OpenRewrite recipe catalog for your target versions.
## comby (any language, structural-ish)
```bash
comby 'assertEquals(:[expected], :[actual])' 'assertThat(:[actual]).isEqualTo(:[expected])' .java -d src/test # preview diff
comby 'assertEquals(:[expected], :[actual])' 'assertThat(:[actual]).isEqualTo(:[expected])' .java -d src/test -i # in place
```
`:[hole]` matches balanced text including nested parentheses; good for languages without
an ast-grep grammar, or templates. It is not type-aware; review matches carefully.
## Official codemods first
Before writing your own for a framework upgrade, check for maintained ones:
`npx @next/codemod`, `npx codemod react/19/...` (codemod.com registry),
`npx storybook automigrate`, `ng update` (Angular schematics), `pyupgrade`,
`django-upgrade`, `ruff check --fix` with `UP` rules, `go fix`, `cargo fix --edition`,
`dotnet upgrade-assistant`. Use them, then hand-fix the remainder.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.