Batch operations across files with confidence-based auto-apply. Use for renaming, search-replace, refactoring code, updating text/markdown, migrating terminology, and API migrations. Run `bun tools/refactor.ts --help` for detailed command reference.
From batch-refactornpx claudepluginhub beorn/bearly --plugin batch-refactorThis skill is limited to using the following tools:
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Migrates code, prompts, and API calls from Claude Sonnet 4.0/4.5 or Opus 4.1 to Opus 4.5, updating model strings on Anthropic, AWS, GCP, Azure platforms.
Configures VPN and dedicated connections like Direct Connect, ExpressRoute, Interconnect for secure on-premises to AWS, Azure, GCP, OCI hybrid networking.
Quick start: Run bun tools/refactor.ts --help for command reference and examples.
Use this skill when the user wants to make changes across multiple files:
Trigger phrases:
⛔ NEVER use these for batch changes: sed, awk, perl, python -c, or manual Edit loops.
These tools lack checksums, miss edge cases, and can corrupt files. See "Tips & Tricks → STOP: Manual Edit Tools" below.
ALWAYS use this tool instead of manual editing when:
| Situation | Manual Edits | Batch Refactor |
|---|---|---|
| Rename a function used in 47 files | 47 separate Edit calls | 1 command |
| Rename parameter in destructuring patterns | Miss edge cases | Catches all patterns |
| Update terminology across codebase | Hours of work | Minutes |
| Rename file + update all imports | Break builds | Atomic, safe |
Task: Rename createWidget to createGadget
Manual approach (WRONG):
# You'd miss these patterns:
const { createWidget } = api # destructuring
const init = createWidget # assignment
type Factory = typeof createWidget # type reference
async ({ createWidget }: Deps) => {} # parameter destructuring
Batch refactor approach (CORRECT):
bun tools/refactor.ts rename.batch --pattern createWidget --replace createGadget --output edits.json
bun tools/refactor.ts editset.apply edits.json
# Finds ALL 127 references including destructuring, types, re-exports
| You want to... | Command | Example |
|---|---|---|
| Rename TypeScript function/variable | rename.batch | --pattern createWidget --replace createGadget |
| Rename TypeScript type/interface | rename.batch | --pattern WidgetConfig --replace GadgetConfig |
| Rename files | file.rename | --pattern widget --replace repo --glob "**/*.ts" |
| Update text in markdown | pattern.replace --backend ripgrep | --pattern widget --replace repo --glob "**/*.md" |
| Full terminology migration | migrate | --from widget --to repo |
| API migration (complex patterns) | pattern.migrate | --patterns "oldApi()" --prompt "migrate to newApi" |
For large terminology migrations (e.g., "rename widget to repo"), follow this phased approach:
CRITICAL: Analyze ALL conflicts before making ANY changes.
# Run from the batch plugin directory
# 1. Check file name conflicts
bun tools/refactor.ts file.rename --pattern widget --replace repo --glob "**/*.ts" --check-conflicts
# 2. Check symbol conflicts
bun tools/refactor.ts rename.batch --pattern widget --replace repo --check-conflicts
# 3. Check for existing targets manually
ls **/repo*.ts 2>/dev/null || echo "No existing repo files"
For each conflict, document resolution:
| Conflict | Resolution |
|---|---|
widget.ts → repo.ts (exists) | Merge and delete |
createWidget → createRepo (exists) | Update references, keep new |
Never use --skip without explicit user approval.
Rename files FIRST (before symbol renames) because:
# Create file rename proposal
bun tools/refactor.ts file.rename --pattern widget --replace repo \
--glob "**/*.{ts,tsx}" \
--output file-editset.json
# Preview
bun tools/refactor.ts file.apply file-editset.json --dry-run
# Apply
bun tools/refactor.ts file.apply file-editset.json
After files are renamed, rename symbols:
# Create symbol rename proposal
bun tools/refactor.ts rename.batch --pattern widget --replace repo \
--output symbol-editset.json
# Preview
bun tools/refactor.ts editset.apply symbol-editset.json --dry-run
# Apply
bun tools/refactor.ts editset.apply symbol-editset.json
Rename remaining mentions in comments, strings, markdown:
# TypeScript comments and strings
bun tools/refactor.ts pattern.replace --pattern widget --replace repo \
--glob "**/*.{ts,tsx}" \
--backend ripgrep \
--output text-editset.json
# Markdown documentation
bun tools/refactor.ts pattern.replace --pattern widget --replace repo \
--glob "**/*.md" \
--backend ripgrep \
--output docs-editset.json
# Preview and apply each
bun tools/refactor.ts editset.apply text-editset.json --dry-run
bun tools/refactor.ts editset.apply text-editset.json
For changes in git submodules:
# For each vendor submodule with matches
cd vendor/<submodule>
# Run the same workflow (conflicts, files, symbols, text)
bun tools/refactor.ts rename.batch \
--pattern widget --replace repo --check-conflicts
# After applying
git add -A
git commit -m "refactor: rename widget → repo"
git push
# Return to main repo
cd ../..
git add vendor/<submodule>
git commit -m "chore(vendor): update <submodule> with repo terminology"
# Check for remaining mentions
grep -ri widget . --include="*.ts" --include="*.tsx" | grep -v node_modules | wc -l
# Type check
bun tsc --noEmit
# Lint and fix
bun fix
# Run tests
bun run test:all
| What you're changing | File Type | Backend | Command |
|---|---|---|---|
| File names | any | file-ops | file.rename |
| TypeScript/JS identifiers | .ts, .tsx, .js, .jsx | ts-morph | rename.batch |
| API patterns (complex) | .ts, .tsx | LLM | pattern.migrate |
| Go, Rust, Python structural patterns | .go, .rs, .py | ast-grep | pattern.replace |
| JSON/YAML values | .json, .yaml | ast-grep | pattern.replace |
| Text/markdown/comments | .md, .txt, any | ripgrep | pattern.replace |
| Wiki links only | .md | wikilink | wikilink.rename |
| package.json paths | package.json | package-json | package.rename |
| tsconfig.json paths | tsconfig*.json | tsconfig-json | tsconfig.rename |
file.rename auto-detects file type and updates references:
.ts/.tsx/.js/.jsx → updates import paths.md/.markdown/.mdx → updates [[wikilinks]] (Obsidian, Foam, etc.)package.json → updates exports, main, types, bin pathstsconfig.json → updates paths mappings, includes, referencesCRITICAL for TypeScript: Always use ts-morph (via rename.batch) for identifiers. It handles destructuring, arrow function params, and nested scopes that text-based tools miss.
Dependencies:
sg CLI (brew install ast-grep)rg CLI (usually pre-installed)| Command | Purpose |
|---|---|
file.find --pattern <p> --replace <r> [--glob] | Find files to rename |
file.rename --pattern <p> --replace <r> [--glob] [--output] [--check-conflicts] | Create file rename proposal |
file.verify <file> | Verify file editset can be applied |
file.apply <file> [--dry-run] | Apply file renames |
| Command | Purpose |
|---|---|
symbol.at <file> <line> [col] | Find symbol at location |
refs.list <symbolKey> | List all references to a symbol |
symbols.find --pattern <regex> | Find symbols matching pattern |
rename.propose <key> <new> | Single symbol rename proposal |
rename.batch --pattern <p> --replace <r> [--check-conflicts] | Batch rename proposal |
| Command | Purpose |
|---|---|
pattern.find --pattern <p> [--glob] [--backend] | Find structural patterns |
pattern.replace --pattern <p> --replace <r> [--glob] [--backend] | Pattern replace proposal |
pattern.migrate --patterns <p1,p2> --prompt <text> [--glob] | LLM-powered API migration |
backends.list | List available backends |
| Command | Purpose |
|---|---|
editset.select <file> --include/--exclude | Filter editset refs |
editset.verify <file> | Check editset can be applied |
editset.apply <file> [--dry-run] | Apply with checksum verification |
| Command | Purpose |
|---|---|
package.find --target <file> | Find package.json refs to a file |
package.rename --old <path> --new <path> | Update paths when file renamed |
package.broken | Find broken paths in package.json |
| Command | Purpose |
|---|---|
tsconfig.find --target <file> | Find tsconfig.json refs to a file |
tsconfig.rename --old <path> --new <path> | Update paths when file renamed |
Scenario: Rename all files containing "user-service" to "account-service"
# 1. Find files that would be renamed
bun tools/refactor.ts file.find --pattern "user-service" --replace "account-service" --glob "**/*.ts"
# 2. Check for conflicts (target files already exist?)
bun tools/refactor.ts file.rename --pattern "user-service" --replace "account-service" \
--glob "**/*.ts" --check-conflicts
# 3. Create editset
bun tools/refactor.ts file.rename --pattern "user-service" --replace "account-service" \
--glob "**/*.ts" --output file-renames.json
# 4. Preview (dry run)
bun tools/refactor.ts file.apply file-renames.json --dry-run
# 5. Apply
bun tools/refactor.ts file.apply file-renames.json
Result:
src/user-service.ts → src/account-service.tssrc/testing/mock-user-service.ts → src/testing/mock-account-service.tsUserServiceConfig.ts → AccountServiceConfig.ts (case preserved)Scenario: Update all imports that reference renamed files
# After renaming user-service.ts → account-service.ts, update imports:
bun tools/refactor.ts pattern.replace \
--pattern "user-service" \
--replace "account-service" \
--glob "**/*.ts" \
--backend ripgrep \
--output import-updates.json
bun tools/refactor.ts editset.apply import-updates.json --dry-run
bun tools/refactor.ts editset.apply import-updates.json
Before:
import { createUser } from "./user-service"
import { UserService } from "../services/user-service"
After:
import { createUser } from "./account-service"
import { UserService } from "../services/account-service"
Scenario: Rename function createWidget to createGadget across codebase
# 1. Check for conflicts (does createGadget already exist?)
bun tools/refactor.ts rename.batch --pattern "createWidget" --replace "createGadget" --check-conflicts
# 2. Create editset
bun tools/refactor.ts rename.batch --pattern "createWidget" --replace "createGadget" \
--output symbol-renames.json
# 3. Preview
bun tools/refactor.ts editset.apply symbol-renames.json --dry-run
# 4. Apply
bun tools/refactor.ts editset.apply symbol-renames.json
Handles correctly:
// Function declaration
export function createWidget(config: Config) { } → createGadget
// Arrow function
const createWidget = (opts) => { } → createGadget
// Destructuring
const { createWidget } = api → createGadget
// Parameter
function init({ createWidget }: Deps) { } → createGadget
// Type reference
type Factory = typeof createWidget → createGadget
Scenario: Rename ApiClient to HttpClient across codebase
# Check conflicts
bun tools/refactor.ts rename.batch --pattern "ApiClient" --replace "HttpClient" --check-conflicts
# Create and apply
bun tools/refactor.ts rename.batch --pattern "ApiClient" --replace "HttpClient" \
--output type-renames.json
bun tools/refactor.ts editset.apply type-renames.json
Handles correctly:
// Interface definition
export interface ApiClient { } → HttpClient
// Type alias
type Client = ApiClient → HttpClient
// Variable type annotation
const client: ApiClient = ... → HttpClient
// Generic constraint
function fetch<T extends ApiClient>() → HttpClient
// Import
import type { ApiClient } from "./api" → HttpClient
Scenario: Update documentation and comments from "widget" to "gadget"
# Markdown docs
bun tools/refactor.ts pattern.replace \
--pattern "widget" \
--replace "gadget" \
--glob "**/*.md" \
--backend ripgrep \
--output docs-updates.json
# TypeScript comments and strings
bun tools/refactor.ts pattern.replace \
--pattern "widget" \
--replace "gadget" \
--glob "**/*.ts" \
--backend ripgrep \
--output comment-updates.json
# Preview and apply
bun tools/refactor.ts editset.apply docs-updates.json --dry-run
bun tools/refactor.ts editset.apply docs-updates.json
Updates:
// This creates a new widget → gadget
const msg = "Widget not found" → "Gadget not found"
/** @description Widget factory */ → Gadget factory
# Widget Guide → Gadget Guide
Create a widget with... → Create a gadget with...
Scenario: Migrate Go logging from fmt.Println to log.Info
bun tools/refactor.ts pattern.replace \
--pattern 'fmt.Println($MSG)' \
--replace 'log.Info($MSG)' \
--glob "**/*.go" \
--backend ast-grep \
--output go-logging.json
bun tools/refactor.ts editset.apply go-logging.json
Before:
fmt.Println("Starting server")
fmt.Println(err.Error())
After:
log.Info("Starting server")
log.Info(err.Error())
Scenario: Migrate entire codebase from "user" terminology to "account"
# Phase 1: Conflict Analysis
bun tools/refactor.ts file.rename --pattern "user" --replace "account" --check-conflicts
bun tools/refactor.ts rename.batch --pattern "user" --replace "account" --check-conflicts
# Phase 2: File Renames (FIRST)
bun tools/refactor.ts file.rename --pattern "user" --replace "account" \
--glob "**/*.ts" --output phase2-files.json
bun tools/refactor.ts file.apply phase2-files.json
# Phase 3: Symbol Renames
bun tools/refactor.ts rename.batch --pattern "user" --replace "account" \
--output phase3-symbols.json
bun tools/refactor.ts editset.apply phase3-symbols.json
# Phase 4: Text/Comments
bun tools/refactor.ts pattern.replace --pattern "user" --replace "account" \
--glob "**/*.ts" --backend ripgrep --output phase4-text.json
bun tools/refactor.ts editset.apply phase4-text.json
# Phase 5: Documentation
bun tools/refactor.ts pattern.replace --pattern "user" --replace "account" \
--glob "**/*.md" --backend ripgrep --output phase5-docs.json
bun tools/refactor.ts editset.apply phase5-docs.json
# Phase 6: Verify
grep -ri user . --include="*.ts" | wc -l # Should be 0
bun tsc --noEmit
bun fix
bun run test:all
Scenario: Rename only specific occurrences, not all
# Create full editset
bun tools/refactor.ts rename.batch --pattern "config" --replace "options" \
--output full-editset.json
# Filter to only certain files
bun tools/refactor.ts editset.select full-editset.json \
--include "src/core/**" \
--exclude "src/core/legacy/**" \
--output filtered-editset.json
# Apply filtered set
bun tools/refactor.ts editset.apply filtered-editset.json
The tool preserves case during renames:
| Original | Pattern | Replacement | Result |
|---|---|---|---|
widget | widget | repo | repo |
Repo | widget | repo | Repo |
REPO | widget | repo | REPO |
widgetPath | widget | repo | repoPath |
WidgetConfig.ts | widget | repo | GadgetConfig.ts |
Never skip conflicts without understanding them.
| Conflict Type | Resolution Strategy |
|---|---|
| Target exists (duplicate) | Merge content, delete source |
| Target exists (different) | Rename to avoid collision |
| Same path (no-op) | Skip (no change needed) |
| Conflict Type | Resolution Strategy |
|---|---|
| Target name exists | Check if same symbol (safe to merge) or different (needs rename) |
| Multiple symbols same name | May be scoped (function-local vs module) - often safe |
Process:
--check-conflicts firstBefore making batch changes:
git rev-parse --is-inside-work-tree 2>/dev/null
git status --porcelain
| Situation | Action |
|---|---|
| Git repo, clean working tree | ✅ Proceed |
| Git repo, uncommitted changes | ⚠️ Ask user to commit first |
| Not a git repo | ⚠️ Warn: no undo available |
Before making changes, gather project context:
Read CLAUDE.md - look for:
Check for migration scripts (optional):
scripts/check-migration.ts or similarUnderstand scope:
grep -ri <pattern> . --include="*.ts" | wc -l # Total mentions
find . -name "*<pattern>*" -not -path "./node_modules/*" # File names
Be aggressive. Tests catch mistakes.
| Context | Confidence |
|---|---|
Our code (const widgetRoot) | HIGH |
Our compound identifier (widgetHelper) | HIGH |
Our error message ("widget not found") | HIGH |
External reference ("Obsidian widget") | LOW - may need to keep |
URL/path (widget.example.com) | LOW |
Default to HIGH unless clearly external.
ast-grep misses TypeScript-specific patterns:
// ast-grep renames this ✓
const widgetDir = "/path"
// But MISSES these ✗
interface TestEnv { widgetDir: string } // property definition
({ widgetDir }) => { ... } // destructuring
Rule: If it shows up in "Find All References" in your IDE, use ts-morph.
User request: "rename widget to repo everywhere"
Claude's plan:
Analyze scope
grep -ri widget . --include="*.ts" | wc -l
find . -name "*widget*" -not -path "./node_modules/*"
Check ALL conflicts
# File conflicts
bun tools/refactor.ts file.rename --pattern widget --replace repo --check-conflicts
# Symbol conflicts
bun tools/refactor.ts rename.batch --pattern widget --replace repo --check-conflicts
Document conflict resolutions (ask user if unclear)
Execute in phases:
Verify:
grep -ri widget . --include="*.ts" | wc -l # Should be 0 (or only allowed)
bun tsc --noEmit
bun fix
bun run test:all
Editsets now include enriched context fields that allow an LLM to review and selectively modify replacements before applying. This enables intelligent, context-aware refactoring decisions.
Each reference in an editset includes:
| Field | Type | Description |
|---|---|---|
kind | "call" | "decl" | "type" | "string" | "comment" | What kind of reference this is |
scope | string | null | Enclosing function/class name, or null for module-level |
ctx | string[] | Array of context lines with ► marker on the matching line |
replace | string | null | null to skip this reference, string for custom replacement |
# Run from the batch plugin directory
# 1. Generate editset with enriched context
bun tools/refactor.ts rename.batch --pattern widget --replace repo -o editset.json
# 2. LLM reviews the editset and patches specific references
# - Set replace to null to skip a reference
# - Set replace to a custom string for non-standard replacement
bun tools/refactor.ts editset.patch editset.json <<'EOF'
{ "b2c3": "Repository", "c3d4": null }
EOF
# 3. Apply the patched editset
bun tools/refactor.ts editset.apply editset.json
Given an editset with these references:
{
"refs": [
{
"id": "a1b2",
"kind": "decl",
"scope": "createWidget",
"replace": "repo"
},
{ "id": "b2c3", "kind": "type", "scope": null, "replace": "repo" },
{ "id": "c3d4", "kind": "comment", "scope": null, "replace": "repo" }
]
}
An LLM might decide:
a1b2 as-is (standard replacement)b2c3 to "Repository" (capitalize for type name)c3d4 to null (skip comment, it refers to external Obsidian widget)bun tools/refactor.ts editset.patch editset.json <<'EOF'
{ "b2c3": "Repository", "c3d4": null }
EOF
The ctx field shows surrounding lines with the match marked:
{
"ctx": [" // Create a new widget for the user", "► const vault = createVault(config)", " return widget"]
}
This helps LLMs understand whether a reference is:
The migrate command orchestrates a full terminology migration in phases:
# Run from the batch plugin directory
# Preview what would be migrated
bun tools/refactor.ts migrate --from widget --to repo --dry-run
# Run migration (creates editsets in .editsets/ directory)
bun tools/refactor.ts migrate --from widget --to repo
# Custom output directory
bun tools/refactor.ts migrate --from widget --to repo --output ./my-editsets
# Custom file glob
bun tools/refactor.ts migrate --from widget --to repo --glob "**/*.{ts,tsx,md}"
| Phase | Description | Output File |
|---|---|---|
| 1. File renames | Rename files containing pattern | 01-file-renames.json |
| 2. Symbol renames | TypeScript identifiers via ts-morph | 02-symbol-renames.json |
| 3. Text patterns | Comments, strings via ripgrep | 03-text-patterns.json |
After running, review each editset and apply:
# Review and apply each phase
bun tools/refactor.ts file.apply .editsets/01-file-renames.json --dry-run
bun tools/refactor.ts file.apply .editsets/01-file-renames.json
bun tools/refactor.ts editset.apply .editsets/02-symbol-renames.json --dry-run
bun tools/refactor.ts editset.apply .editsets/02-symbol-renames.json
bun tools/refactor.ts editset.apply .editsets/03-text-patterns.json --dry-run
bun tools/refactor.ts editset.apply .editsets/03-text-patterns.json
| Flag | Description | Default |
|---|---|---|
--from <pattern> | Pattern to match (required) | - |
--to <replacement> | Replacement string (required) | - |
--glob <glob> | File glob filter | **/*.{ts,tsx} |
--dry-run | Preview without creating editsets | false |
--output <dir> | Output directory for editsets | .editsets |
For complex API migrations where simple find/replace isn't enough, use pattern.migrate. This command:
Use pattern.migrate instead of pattern.replace when:
await, changing variable names)# OLD API:
# const { lastFrame, stdin } = render(<App />)
# expect(stripAnsi(lastFrame())).toContain('Hello')
# stdin.write('\x1b[A')
# NEW API:
# const app = render(<App />)
# expect(app.text).toContain('Hello')
# await app.press('ArrowUp')
bun tools/refactor.ts pattern.migrate \
--patterns "lastFrame(),stdin.write,= render(" \
--glob "**/*.test.tsx" \
--prompt "Migrate old render() API to new App API:
- const { lastFrame, stdin } = render(...) → const app = render(...)
- lastFrame() → app.ansi
- stripAnsi(lastFrame()) → app.text
- stdin.write('\\x1b[A') → await app.press('ArrowUp')
- stdin.write('\\x1b[B') → await app.press('ArrowDown')" \
--output /tmp/migrate.json
# Review
jq '.refs[:5]' /tmp/migrate.json
# Apply
bun tools/refactor.ts editset.apply /tmp/migrate.json
| Flag | Description | Default |
|---|---|---|
--patterns <p1,p2,...> | Comma-separated search patterns (required) | - |
--prompt <text> | Migration instructions for LLM (required) | - |
--glob <glob> | File filter | **/*.{ts,tsx} |
--output <file> | Editset output file | /tmp/migrate.json |
--model <model> | Override LLM model | best available |
--dry-run | Preview prompt without calling LLM | false |
# 1. Dry run - see what would be sent to LLM
bun tools/refactor.ts pattern.migrate \
--patterns "oldPattern" \
--glob "**/*.ts" \
--prompt "Migrate to new pattern" \
--dry-run
# 2. Run migration
bun tools/refactor.ts pattern.migrate \
--patterns "oldPattern" \
--glob "**/*.ts" \
--prompt "Migrate to new pattern" \
--output /tmp/migrate.json
# 3. Review editset
jq '.refs | length' /tmp/migrate.json # Count changes
jq '.refs[:3]' /tmp/migrate.json # Preview first 3
# 4. Apply
bun tools/refactor.ts editset.apply /tmp/migrate.json
# 5. Verify
bun tsc --noEmit && bun run test:fast
The LLM sees each match with ~3 lines of context. Write prompts that:
stripAnsi() wrappers)await)# Good prompt example:
--prompt "Migrate test API:
- const { lastFrame, stdin } = render(...) → const app = render(...)
- lastFrame() → app.ansi
- stripAnsi(lastFrame()) → app.text (remove stripAnsi wrapper)
- stdin.write('x') → await app.press('x')
- stdin.write('\\x1b[A') → await app.press('ArrowUp')
- stdin.write('\\x1b[B') → await app.press('ArrowDown')
- stdin.write('\\x1b[C') → await app.press('ArrowRight')
- stdin.write('\\x1b[D') → await app.press('ArrowLeft')
- stdin.write('\\r') → await app.press('Enter')"
When an LLM reviews an editset, it sees enriched context for each reference:
{
"id": "rename-widget-to-repo-1706123456789",
"operation": "rename",
"from": "widget",
"to": "repo",
"refs": [
{
"refId": "a1b2c3d4",
"file": "src/storage.ts",
"line": 45,
"range": [45, 12, 45, 17],
"kind": "call",
"scope": "initStorage",
"ctx": [" function initStorage() {", "► const root = createVault(config);", " return root;"],
"replace": "repo",
"preview": "const root = createWidget(config);",
"checksum": "abc123...",
"selected": true
},
{
"refId": "b2c3d4e5",
"file": "src/errors.ts",
"line": 12,
"range": [12, 25, 12, 30],
"kind": "string",
"scope": "errorHandler",
"ctx": ["► throw new Error(\"vault not found\");"],
"replace": "repo",
"preview": "throw new Error(\"widget not found\");",
"checksum": "def456...",
"selected": true
}
],
"edits": [
/* ... byte-level edits */
],
"createdAt": "2024-01-24T12:00:00.000Z"
}
| Field | Type | Description |
|---|---|---|
refId | string | Stable identifier for this reference |
kind | "call" | "decl" | "type" | "string" | "comment" | Semantic kind |
scope | string | null | Enclosing function/class, or null for module-level |
ctx | string[] | Context lines with ► marker on match line |
replace | string | null | Replacement text, or null to skip |
line | number | 1-indexed line number |
range | [number, number, number, number] | [startLine, startCol, endLine, endCol] |
Apply LLM-generated patches to editsets via stdin (heredoc):
# Minimal patch format: refId → replacement or null
bun tools/refactor.ts editset.patch editset.json <<'EOF'
{
"b2c3d4e5": "repository",
"c3d4e5f6": null
}
EOF
# Or pipe from a file
cat my-patch.json | bun tools/refactor.ts editset.patch editset.json
# Output to different file
bun tools/refactor.ts editset.patch editset.json --output patched.json <<'EOF'
{ "b2c3": null }
EOF
The patch is a simple JSON object mapping refIds to actions:
{
"refId1": "custom replacement", // Use this replacement instead of default
"refId2": null // Skip this reference
// refId3 not mentioned // Apply with default replacement
}
| Patch Value | Action |
|---|---|
"string" | Use this replacement text |
null | Skip this reference (don't apply) |
| (not in patch) | Apply with default to replacement |
You can also pass a full editset with modified replace fields:
{
"refs": [
{ "refId": "a1b2", "replace": "Repository" },
{ "refId": "b2c3", "replace": null }
]
}
The patch command extracts refId and replace from each ref.
Scenario: Rename widgetPath to repoPath — appears in destructuring patterns
Manual grep would find:
const widgetPath = "/path/to/widget" // ✓ obvious
But MISS these (ts-morph catches them):
// Destructuring in function parameter
export function init({ widgetPath, config }: Options) {
return load(widgetPath)
}
// Nested destructuring
const {
paths: { widgetPath },
} = config
// Arrow function parameter destructuring
const handler = ({ widgetPath }: Ctx) => widgetPath
// Object shorthand
return { widgetPath } // property AND value both renamed
Command:
bun tools/refactor.ts rename.batch --pattern widgetPath --replace repoPath --output edits.json
bun tools/refactor.ts editset.apply edits.json
# All 47 occurrences updated atomically
Scenario: Rename Widget to Gadget — used in index.ts re-exports
Manual editing misses:
// src/index.ts - barrel file
export { Widget } from "./widget"
export type { Widget, WidgetConfig } from "./widget"
// src/components/index.ts - nested barrel
export * from "./Widget"
export { Widget as DefaultWidget } from "./Widget"
ts-morph finds ALL of these:
bun tools/refactor.ts rename.batch --pattern Widget --replace Gadget --output edits.json
# Found: 89 references across 23 files including all re-exports
Scenario: Rename UserService interface — used in type-only imports
Manual editing risks:
// Type-only import - easy to miss with grep
import type { UserService } from "./services"
// Inline type import
const fn = (service: import("./services").UserService) => {}
// Generic constraints
function process<T extends UserService>(svc: T) {}
ts-morph finds all patterns:
bun tools/refactor.ts rename.batch --pattern UserService --replace AccountService --check-conflicts
bun tools/refactor.ts rename.batch --pattern UserService --replace AccountService --output edits.json
Scenario: Rename parseConfig — referenced in JSDoc comments
/**
* @see parseConfig for config format
* @param {ReturnType<typeof parseConfig>} config
*/
function initApp(config) {}
ripgrep backend catches JSDoc:
bun tools/refactor.ts pattern.replace \
--pattern parseConfig \
--replace loadConfig \
--glob "**/*.{ts,js}" \
--backend ripgrep \
--output jsdoc-updates.json
Scenario: Rename file user-api.ts to account-api.ts — dynamic imports exist
// Static import (caught by ts-morph file.rename)
import { getUser } from "./user-api"
// Dynamic import (caught by ripgrep pattern.replace)
const api = await import("./user-api")
const { handler } = await import(`./user-api`)
Two-step approach:
# 1. Rename file + static imports
bun tools/refactor.ts file.rename --pattern user-api --replace account-api --output files.json
bun tools/refactor.ts file.apply files.json
# 2. Catch dynamic imports
bun tools/refactor.ts pattern.replace \
--pattern "user-api" \
--replace "account-api" \
--glob "**/*.ts" \
--backend ripgrep \
--output dynamic.json
bun tools/refactor.ts editset.apply dynamic.json
Scenario: Rename createWidget — but test mocks use it too
// src/widget.ts
export function createWidget() {}
// tests/widget.test.ts
vi.mock("../widget", () => ({
createWidget: vi.fn(), // Mock uses same name
}))
// tests/fixtures/widget-fixture.ts
export const mockCreateWidget = () => {} // Compound identifier
ts-morph renames ALL including mocks:
bun tools/refactor.ts rename.batch --pattern createWidget --replace createGadget --output edits.json
# Found in: src/widget.ts, 12 test files, 3 fixture files
Scenario: Rename all widget occurrences — different casings exist
const widget = {} // lowercase
const Repo = {} // PascalCase
const REPO_PATH = "" // SCREAMING_CASE
const widgetConfig = {} // camelCase compound
class RepoManager {} // PascalCase compound
const REPO_OPTIONS = {} // SCREAMING compound
Automatic case preservation:
bun tools/refactor.ts rename.batch --pattern widget --replace repo --output edits.json
Result:
const repo = {} // lowercase preserved
const Repo = {} // PascalCase preserved
const REPO_PATH = "" // SCREAMING_CASE preserved
const repoConfig = {} // camelCase compound
class RepoManager {} // PascalCase compound
const REPO_OPTIONS = {} // SCREAMING compound
Scenario: Rename across multiple packages in a monorepo
packages/
core/src/widget.ts
cli/src/commands/widget.ts
tui/src/views/widget-view.tsx
storage/src/widget-loader.ts
Single command handles all:
bun tools/refactor.ts migrate --from widget --to repo --glob "packages/**/*.{ts,tsx}"
# Creates editsets in .editsets/:
# - 01-file-renames.json (4 files)
# - 02-symbol-renames.json (234 symbols)
# - 03-text-patterns.json (89 text occurrences)
Scenario: Someone edited a file after you created the editset
# Create editset
bun tools/refactor.ts rename.batch --pattern Widget --replace Gadget --output edits.json
# ... time passes, teammate edits src/widget.ts ...
# Apply fails safely with drift detection
bun tools/refactor.ts editset.apply edits.json
# Error: Drift detected in src/widget.ts (checksum mismatch)
# Skipped: 3 edits in src/widget.ts
# Applied: 45 edits in other files
The editset NEVER corrupts files — if the file changed, it skips that file.
Is this a terminology migration (file names + code + docs)?
├── YES → `migrate --from X --to Y`
└── NO
├── Is this an API migration (complex pattern changes)?
│ └── YES → `pattern.migrate --patterns X --prompt "..."` (LLM-powered)
├── Renaming files?
│ └── YES → `file.rename --pattern X --replace Y`
├── Renaming TypeScript identifiers?
│ └── YES → `rename.batch --pattern X --replace Y`
├── Updating Go/Rust/Python structural patterns?
│ └── YES → `pattern.replace --backend ast-grep`
└── Updating text/markdown/comments?
└── YES → `pattern.replace --backend ripgrep`
Use pattern.migrate when:
await, changing variable scope)| Task | Manual Edits | Batch Refactor |
|---|---|---|
| Rename function (50 refs) | ~50 Edit calls | 2 commands |
| Rename file + imports | Risk broken build | Atomic update |
| Full terminology migration | Hours | Minutes |
| Rollback on error | Manual git restore | Automatic (checksums) |
Rule of thumb: If you'd make more than 5 edits, use batch refactor
IMMEDIATELY STOP AND THINK if you're about to use any of these:
| Tool | What it does | Why you should stop |
|---|---|---|
sed | Stream editing | batch-refactor does this better with checksums |
awk | Pattern processing | batch-refactor handles this |
perl -pe | Regex replacement | batch-refactor does this safely |
python -c | Quick scripts | batch-refactor is purpose-built for this |
Manual Edit tool in loop | Many small edits | batch-refactor does this atomically |
Before using these, ask yourself:
pattern.replace or rename.batchpattern.replace --backend ripgreprename.batch (ts-morph)pattern.replace --backend ast-grepIf batch-refactor CAN'T do what you need:
bd create --title "batch-refactor: support X" --type=taskThe goal: Every manual edit is a signal that batch-refactor is missing a feature.
Storing editsets outside the repo avoids cluttering your working directory and needing grep -v .editsets when verifying:
# Instead of:
bun tools/refactor.ts migrate --from widget --to repo -o .editsets
rg -l widget -g "*.ts" | grep -v .editsets | wc -l # Annoying
# Do this:
bun tools/refactor.ts migrate --from widget --to repo -o /tmp/editsets
rg -l widget -g "*.ts" | wc -l # Clean
Before applying, inspect what an editset will do:
# Quick look at the editset structure and refs
cat /tmp/editset.json | head -50
# See how many refs/edits
cat /tmp/editset.json | jq '{refs: .refs | length, edits: .edits | length}'
# Dry-run shows what would be applied
bun tools/refactor.ts editset.apply /tmp/editset.json --dry-run
Before running batch operations, verify your patterns find what you expect:
# Find files containing pattern
rg -l "useRepo" -g "*.ts"
# Show matches with line numbers
rg -n "useRepo" -g "*.ts"
# Count matches
rg -c "widget" -g "*.ts" | head -20
# Show context around matches
rg -C 2 "createWidget" -g "*.ts"
rg --files lists files matching globs, respecting .gitignore. One tool for everything:
# Instead of find (slow, doesn't respect .gitignore):
find . -name "*widget*" -not -path "./node_modules/*"
# Use rg --files with glob:
rg --files -g "*widget*" # Files with "widget" in name
rg --files -g "*.ts" | head -10 # All .ts files
rg --files -g "*widget*.ts" # .ts files with "widget" in name
rg --files packages/ # All files in packages/
Shell loops with rg are slow. Use rg's built-in features instead:
# SLOW - shell loop overhead - DON'T DO THIS
rg -l "widget" | while read f; do
echo "=== $f ==="
rg -n "widget" "$f"
done
# FAST - single rg invocation
rg -n "widget" -g "*.ts" # Filename:line:match
rg --heading -n "widget" -g "*.ts" # Group by filename
rg --vimgrep "widget" -g "*.ts" # file:line:col:match
Test glob patterns with rg directly before using them in batch commands:
# Test the glob finds expected files
rg --files -g "**/*.ts" | head -10
rg --files -g "apps/**/*.tsx" | head -10
# Then use same glob in batch command
bun tools/refactor.ts pattern.replace --pattern widget --glob "apps/**/*.tsx" -o /tmp/edits.json
After migration, verify zero remaining mentions:
# Fast count of remaining mentions
rg -c "widget" -g "*.ts" --stats
# List files still containing pattern
rg -l "widget" -g "*.ts" -g "*.tsx"
# Exclude vendor/node_modules (fd-style filtering)
rg -l "widget" -g "*.ts" -g "!vendor/**" -g "!node_modules/**"
If a batch command returns 0 results unexpectedly:
# 1. Verify rg finds matches directly
rg -l "pattern" -g "*.ts"
# 2. Check the glob syntax
# Bad: --glob "**/*.{ts,tsx}" (shell may expand braces)
# Good: --glob "**/*.ts" --glob "**/*.tsx" (separate globs)
# 3. Run with verbose output
DEBUG=* bun tools/refactor.ts pattern.replace --pattern widget --glob "**/*.ts"