Claude Code Plugins

Community-maintained marketplace

Feedback

jscodeshift-codemods

@third774/dotfiles
2
0

Write and debug AST-based codemods using jscodeshift for automated code transformations. Use when creating migrations, API upgrades, pattern standardization, or large-scale refactoring.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name jscodeshift-codemods
description Write and debug AST-based codemods using jscodeshift for automated code transformations. Use when creating migrations, API upgrades, pattern standardization, or large-scale refactoring.

jscodeshift Codemods

Core Philosophy: Transform AST nodes, not text. Let recast handle printing to preserve formatting and structure.

1. Always use TDD - write failing tests before implementing transforms 2. Transform the minimal AST necessary - surgical changes preserve formatting 3. Handle edge cases explicitly - codemods run on thousands of files

When to Use

Use codemods for:

  • API migrations - Library upgrades (React Router v5→v6, enzyme→RTL)
  • Pattern standardization - Enforce coding conventions across codebase
  • Deprecation removal - Remove deprecated APIs systematically
  • Large-scale refactoring - Rename functions, restructure imports, update patterns

Don't use codemods for:

  • One-off changes (faster to do manually)
  • Changes requiring semantic understanding (business logic)
  • Non-deterministic transformations

Codemod Workflow

Copy this checklist and track your progress:

Codemod Progress:
- [ ] Phase 1: Identify Patterns
  - [ ] Collect before/after examples from real code
  - [ ] Document transformation rules
  - [ ] Identify edge cases
- [ ] Phase 2: Create Test Fixtures
  - [ ] Create input fixture with pattern to transform
  - [ ] Create expected output fixture
  - [ ] Verify test fails (TDD)
- [ ] Phase 3: Implement Transform
  - [ ] Find target nodes
  - [ ] Apply transformation
  - [ ] Return modified source
- [ ] Phase 4: Handle Edge Cases
  - [ ] Add fixtures for edge cases
  - [ ] Handle already-transformed code (idempotency)
  - [ ] Handle missing dependencies
- [ ] Phase 5: Validate at Scale
  - [ ] Dry run on target codebase
  - [ ] Review sample of changes
  - [ ] Run with --fail-on-error

Project Structure

Standard codemod project layout:

codemods/
├── my-transform.ts                    # Transform implementation
├── __tests__/
│   └── my-transform-test.ts           # Test file
└── __testfixtures__/
    ├── my-transform.input.ts          # Input fixture
    ├── my-transform.output.ts         # Expected output
    ├── edge-case.input.ts             # Additional fixtures
    └── edge-case.output.ts

Transform Module Anatomy

Every transform exports a function with this signature:

import type { API, FileInfo, Options } from "jscodeshift";

export default function transform(
  fileInfo: FileInfo,
  api: API,
  options: Options
): string | null | undefined {
  const j = api.jscodeshift;
  const root = j(fileInfo.source);

  // Find and transform nodes
  root
    .find(j.Identifier, { name: "oldName" })
    .forEach((path) => {
      path.node.name = "newName";
    });

  // Return transformed source, null to skip, or undefined for no change
  return root.toSource();
}

Return values:

Return Meaning
string Transformed source code
null Skip this file (no output)
undefined No changes made

Key objects:

Object Purpose
fileInfo.source Original file contents
fileInfo.path File path being transformed
api.jscodeshift The jscodeshift library (usually aliased as j)
api.stats Collect statistics during dry runs
api.report Print to stdout

Testing with defineTest

jscodeshift provides fixture-based testing utilities:

// __tests__/my-transform-test.ts
jest.autoMockOff();
const defineTest = require("jscodeshift/dist/testUtils").defineTest;

// Basic test - uses my-transform.input.ts → my-transform.output.ts
defineTest(__dirname, "my-transform");

// Named fixtures for edge cases
defineTest(__dirname, "my-transform", null, "already-transformed");
defineTest(__dirname, "my-transform", null, "missing-import");
defineTest(__dirname, "my-transform", null, "multiple-occurrences");

Fixture naming:

__testfixtures__/
├── my-transform.input.ts              # Default input
├── my-transform.output.ts             # Default output
├── already-transformed.input.ts       # Named fixture input
├── already-transformed.output.ts      # Named fixture output

Running tests:

# Run all codemod tests
npx jest codemods/__tests__/

# Run specific transform tests
npx jest codemods/__tests__/my-transform-test.ts

# Run with verbose output
npx jest codemods/__tests__/my-transform-test.ts --verbose

Collection API Quick Reference

The jscodeshift Collection API provides chainable methods:

Method Purpose Example
find(type, filter?) Find nodes by type root.find(j.CallExpression, { callee: { name: 'foo' } })
filter(predicate) Filter collection .filter(path => path.node.arguments.length > 0)
forEach(callback) Iterate and mutate .forEach(path => { path.node.name = 'new' })
replaceWith(node) Replace matched nodes .replaceWith(j.identifier('newName'))
remove() Remove matched nodes .remove()
insertBefore(node) Insert before each match .insertBefore(j.importDeclaration(...))
insertAfter(node) Insert after each match .insertAfter(j.expressionStatement(...))
closest(type) Find nearest ancestor .closest(j.FunctionDeclaration)
get() Get first path .get()
paths() Get all paths as array .paths()
size() Count matches .size()

Chaining pattern:

root
  .find(j.CallExpression, { callee: { name: "oldFunction" } })
  .filter((path) => path.node.arguments.length === 2)
  .forEach((path) => {
    path.node.callee.name = "newFunction";
  });

Common Node Types

Node Type Represents Example Code
Identifier Variable/function names foo, myVar
CallExpression Function calls foo(), obj.method()
MemberExpression Property access obj.prop, arr[0]
ImportDeclaration Import statements import { x } from 'y'
ImportSpecifier Named imports { x } in import
ImportDefaultSpecifier Default imports x in import x from
VariableDeclaration Variable declarations const x = 1
VariableDeclarator Individual variable x = 1 part
FunctionDeclaration Named functions function foo() {}
ArrowFunctionExpression Arrow functions () => {}
ObjectExpression Object literals { a: 1, b: 2 }
ArrayExpression Array literals [1, 2, 3]
Literal Primitive values 'string', 42, true
StringLiteral String values 'hello'

Common Transformation Patterns

Rename Import Source

// Change: import { x } from 'old-package'
// To:     import { x } from 'new-package'

root
  .find(j.ImportDeclaration, { source: { value: "old-package" } })
  .forEach((path) => {
    path.node.source.value = "new-package";
  });

Rename Named Import

// Change: import { oldName } from 'package'
// To:     import { newName } from 'package'

root
  .find(j.ImportSpecifier, { imported: { name: "oldName" } })
  .forEach((path) => {
    path.node.imported.name = "newName";
    // Also rename local if not aliased
    if (path.node.local.name === "oldName") {
      path.node.local.name = "newName";
    }
  });

Add Import If Missing

// Add: import { newThing } from 'package'

const existingImport = root.find(j.ImportDeclaration, {
  source: { value: "package" },
});

if (existingImport.size() === 0) {
  // Add new import at top of file
  const newImport = j.importDeclaration(
    [j.importSpecifier(j.identifier("newThing"))],
    j.literal("package")
  );

  root.find(j.Program).get("body", 0).insertBefore(newImport);
}

Rename Function Calls

// Change: oldFunction(arg)
// To:     newFunction(arg)

root
  .find(j.CallExpression, { callee: { name: "oldFunction" } })
  .forEach((path) => {
    path.node.callee.name = "newFunction";
  });

Transform Function Arguments

// Change: doThing(a, b, c)
// To:     doThing({ a, b, c })

root
  .find(j.CallExpression, { callee: { name: "doThing" } })
  .filter((path) => path.node.arguments.length === 3)
  .forEach((path) => {
    const [a, b, c] = path.node.arguments;
    path.node.arguments = [
      j.objectExpression([
        j.property("init", j.identifier("a"), a),
        j.property("init", j.identifier("b"), b),
        j.property("init", j.identifier("c"), c),
      ]),
    ];
  });

Track Variable Usage Across Scope

// Find what variable an import is bound to, then find all usages

root.find(j.ImportSpecifier, { imported: { name: "useHistory" } }).forEach((path) => {
  const localName = path.node.local.name; // Could be aliased

  // Find all calls using this variable
  root
    .find(j.CallExpression, { callee: { name: localName } })
    .forEach((callPath) => {
      // Transform each usage
    });
});

Replace Entire Expression

// Change: history.push('/path')
// To:     navigate('/path')

root
  .find(j.CallExpression, {
    callee: {
      type: "MemberExpression",
      object: { name: "history" },
      property: { name: "push" },
    },
  })
  .replaceWith((path) => {
    return j.callExpression(j.identifier("navigate"), path.node.arguments);
  });

Anti-Patterns

Over-Matching

// BAD: Matches ANY identifier named 'foo'
root.find(j.Identifier, { name: "foo" });

// GOOD: Match specific context (function calls named 'foo')
root.find(j.CallExpression, { callee: { name: "foo" } });

Ignoring Scope

// BAD: Assumes 'history' always means the router history
root.find(j.Identifier, { name: "history" });

// GOOD: Verify it came from the expected import
const historyImport = root.find(j.ImportSpecifier, {
  imported: { name: "useHistory" },
});
if (historyImport.size() === 0) return; // Skip file

Not Checking Idempotency

// BAD: Adds import every time, even if already present
root.find(j.Program).get("body", 0).insertBefore(newImport);

// GOOD: Check first
const existingImport = root.find(j.ImportDeclaration, {
  source: { value: "package" },
});
if (existingImport.size() === 0) {
  root.find(j.Program).get("body", 0).insertBefore(newImport);
}

Destructive Transforms

// BAD: Rebuilds node from scratch, loses comments and formatting
path.replace(
  j.callExpression(j.identifier("newFn"), [j.literal("arg")])
);

// GOOD: Mutate existing node to preserve metadata
path.node.callee.name = "newFn";

Testing Only Happy Path

// BAD: Only one test fixture
defineTest(__dirname, "my-transform");

// GOOD: Cover edge cases
defineTest(__dirname, "my-transform");
defineTest(__dirname, "my-transform", null, "already-transformed");
defineTest(__dirname, "my-transform", null, "aliased-import");
defineTest(__dirname, "my-transform", null, "no-matching-code");

Debugging Transforms

Dry Run with Print

# See output without writing files
npx jscodeshift -t my-transform.ts target/ --dry --print

Log Node Structure

root.find(j.CallExpression).forEach((path) => {
  console.log(JSON.stringify(path.node, null, 2));
});

Verbose Mode

# Show transformation stats
npx jscodeshift -t my-transform.ts target/ --verbose=2

Fail on Errors

# Exit with code 1 if any file fails
npx jscodeshift -t my-transform.ts target/ --fail-on-error

CLI Quick Reference

# Basic usage
npx jscodeshift -t transform.ts src/

# TypeScript/TSX files
npx jscodeshift -t transform.ts src/ --parser=tsx --extensions=ts,tsx

# Dry run (no changes)
npx jscodeshift -t transform.ts src/ --dry

# Print output to stdout
npx jscodeshift -t transform.ts src/ --print

# Limit parallelism
npx jscodeshift -t transform.ts src/ --cpus=4

# Ignore patterns
npx jscodeshift -t transform.ts src/ --ignore-pattern="**/*.test.ts"

Integration

Complementary skills:

  • writing-tests - For test-first codemod development
  • systematic-debugging - When transforms produce unexpected results
  • verification-before-completion - Verify codemod works before claiming done

Language-specific patterns: