
Codemod React Pipeline
- 72 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
codemod-react-pipeline is a Claude Code skill for frontend development. It helps solo builders move faster with AI-assisted coding.
Key points
- codemod-react-pipeline
- Frontend Development
- AI-coding skill
Codemod React Pipeline by the numbers
- 72 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,149 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill codemod-react-pipelineAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 72 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with frontend development tasks during ai-assisted development?
Helps with frontend development tasks during AI-assisted development.
Who is it for?
Best when you're working on frontend development and need structured help with codemod-react-pipeline.
Skip if: Teams with no frontend development needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with frontend development tasks during ai-assisted development, or when codemod-react-pipeline is a claude code skill for frontend development. it helps solo builders move faster with ai-assisted coding.
What you get
Structured output aligned to codemod-react-pipeline: codemod-react-pipeline; Frontend Development; AI-coding skill.
Files
Codemod React Pipeline
A safe, repeatable workflow for applying JSX / TSX / React codemods to large legacy codebases — from a 50-file rename to a 100k-file API migration. It composes the Codemod CLI (JSSG, ast-grep, workflows) into an inner/outer loop and gates every mass change behind a dry-run plus validation. For the why behind each rule it cites, see the sibling `codemod` best-practices reference.
When to Apply
Use this skill when:
- You're migrating a pattern across a large React/TS codebase (component/prop rename, deprecated
API, import swap, hook migration) and a hand-run codemod jssg run ./t.ts ./src is too risky.
- You need the change to land incrementally and reviewably — per-batch commits, resumable on
failure, with the build/typecheck/tests green at every checkpoint.
- You want to see and validate the findings (what would change, and whether it's safe) before
touching files at scale.
- The transform must be idempotent and proven so before mass apply.
Don't use it for a one-file edit, or a change better done by hand in a single PR.
Workflow Overview
00 plan ─▶ 01 scaffold ─▶ 02 inner loop ─▶ 03 dry-run ─▶ 04 validate ─▶ 05 batched apply ─▶ 06 verify
goal templates fixture tests preview gates per-batch final
+ blast (transform/ + 1-file trial + findings (idempotency, apply+verify+commit assertions
radius rule/wf/tests) report type/lint/test) (resumable)
│ │
└── sentinel ──── required by ──────┘
(on any failure: working tree reverted; fix and re-run — nothing committed yet)| Step | Action | Tool / Script | Risk |
|---|---|---|---|
| 0 | Capture goal, classify transform, count blast radius | scripts/00-plan.sh | read-only |
| 1 | Scaffold transform + fixtures + workflow from templates | scripts/01-scaffold.sh | write (new files) |
| 2 | Fixture tests + single-file trial run (auto-reverted) | scripts/02-inner-loop.sh | read-only (reverts) |
| 3 | Preview changes; write findings report + dry-run sentinel | scripts/03-dry-run.sh | read-only (reverts) |
| 4 | Gate: idempotency, typecheck, lint, format, tests | scripts/04-validate-findings.sh | read-only (reverts) |
| 5 | Apply in resumable batches, verify + commit each | scripts/05-run-batched.sh | destructive (commits) |
| 6 | Final assertions over the whole migration | scripts/verify.sh | read-only |
A PreToolUse hook (hooks/hooks.json) blocks a broad codemod jssg run that lacks --dry-run when no dry-run sentinel exists — so step 5 (or an ad-hoc apply) cannot skip steps 3–4.
Requirements
- Codemod CLI —
npm i -g codemod(or the scripts fall back tonpx codemod). Provides
jssg run/test and workflow run/validate/resume/status.
- git — the pipeline requires a clean tree and makes per-batch checkpoint commits.
- jq — the scripts read
config.jsonwith it. - Your project's gate tooling — TypeScript, ESLint, Prettier, a test runner (whichever gates
you enable in config.json).
Setup
Edit config.json for the target repo (see each field's _setup_instructions):
src_globs: the files the codemod may touch (scopes blast radius + gates)language:tsx|jsx|typescript|javascripttypecheck_cmd/lint_cmd/format_cmd/test_cmd: gate commands (empty disables a gate)batch_size: files per checkpoint in the outer loopgates: per-gate on/off toggles
Add your state_dir (default .codemod-pipeline) to the target repo's .gitignore.
Quick Reference
| Script | Purpose |
|---|---|
scripts/00-plan.sh "<goal>" [name] | Plan + blast-radius estimate |
scripts/01-scaffold.sh <name> [--rule] | Scaffold a JSSG transform (or ast-grep rule) |
| `scripts/02-inner-loop.sh <name> [--watch\ | --file P\ |
scripts/03-dry-run.sh <name> [--sample N] | Preview; write findings + sentinel |
scripts/04-validate-findings.sh <name> | Run validation gates |
| `scripts/05-run-batched.sh <name> [--batch-size N\ | --dry]` |
scripts/verify.sh <name> | Final sign-off assertions |
scripts/selftest.sh | Self-test this skill's scripts/assets |
How to Use
1. From the target repo root, run 00-plan.sh "<goal>" and read the generated plan.md. 2. 01-scaffold.sh <name>, then implement codemods/<name>/transform.ts and its fixtures. 3. Iterate with 02-inner-loop.sh <name> --watch until fixtures pass; try --file <path> on a real file. 4. 03-dry-run.sh <name> and read the findings report; then 04-validate-findings.sh <name>. 5. Only after gates pass: 05-run-batched.sh <name> (resume by re-running it after any fix). 6. verify.sh <name> to confirm the migration is complete and idempotent.
See references/workflow.md for per-step inputs, outputs, failure handling, and rollback; references/inner-outer-loop.md for the mental model; and references/safety-and-scale.md for batching and rollback at 100k-file scale.
Gotchas
See gotchas.md. The big ones: workflow commit: checkpoints are cloud-only (the pipeline commits locally instead); always exclude generated/vendored trees; and design for idempotency from the first line.
Related Skills
- `codemod` — the best-practices reference (48 rules) this pipeline operationalizes.
Steps here cite specific rules (e.g. test-run-on-subset-first, state-use-for-resumability, pattern-ensure-idempotency, security-minimize-capabilities).
# __NAME__/rule.yml — declarative ast-grep rule for __LANGUAGE__
# Generated by codemod-react-pipeline. Reference: https://ast-grep.github.io/guide/rule-config.html
#
# Use a rule (not a JSSG transform) when the change is purely syntactic: one pattern → one
# rewrite, no conditional logic. Rules are fast and deterministic. The moment you need
# "if/else", derived names, or multiple edits per match, switch to transform.ts.
#
# Idempotency: write the pattern so it cannot match its own output (here, <Fieldset> ≠ <FieldGroup>),
# so a second pass is a no-op. The pipeline verifies this in step 04.
id: __NAME__
language: __LANGUAGE__
# WHAT to match. Meta-variables: $X (single node), $$$X (zero+ nodes / a list).
rule:
pattern: <FieldGroup $$$PROPS>$$$CHILDREN</FieldGroup>
# Narrow the match with extra clauses when the pattern alone is too broad:
# constraints:
# PROPS:
# not: { regex: "legacyOnly" }
# inside:
# kind: jsx_expression # only inside JSX expressions, etc.
# HOW to rewrite. References the captured meta-variables.
fix: <Fieldset $$$PROPS>$$$CHILDREN</Fieldset>
# __NAME__/codemod.yaml — package metadata for the Codemod CLI / registry.
# Generated by codemod-react-pipeline. Reference: https://docs.codemod.com
#
# Required to `codemod publish` this codemod; optional for purely local runs.
schema_version: "1.0"
name: __NAME__
version: 0.1.0
description: "TODO: one line describing what __NAME__ migrates"
language: __LANGUAGE__
category: migration
license: MIT
# Keep capability requests minimal — grant only what the transform truly needs (deny-by-default).
# capabilities:
# - fs
targets:
include:
- "src/**/*.{ts,tsx,js,jsx}"
exclude:
- "**/*.test.*"
- "**/*.spec.*"
- "**/*.d.ts"
keywords:
- react
- __LANGUAGE__
- migration
// tests/basic/expected.__EXT__ — code AFTER the __NAME__ transform.
// JSSG compares the transform's output to this file. Update intentionally with `-u` only when
// the new output is genuinely correct (scripts/02-inner-loop.sh __NAME__ -u).
import React from "react";
export function ContactForm() {
return (
<Fieldset className="contact" disabled={false}>
<label>Email</label>
<input name="email" />
</Fieldset>
);
}
// tests/basic/input.__EXT__ — code BEFORE the __NAME__ transform.
// One fixture = one before/after pair. Add a sibling case dir per edge case (already-migrated,
// nested, with-spread-props, …). Run them with: scripts/02-inner-loop.sh __NAME__
import React from "react";
export function ContactForm() {
return (
<FieldGroup className="contact" disabled={false}>
<label>Email</label>
<input name="email" />
</FieldGroup>
);
}
// __NAME__/transform.ts — JSSG transform for __LANGUAGE__
// Generated by codemod-react-pipeline. API: https://docs.codemod.com/jssg/reference
//
// A JSSG transform receives the parsed file (`root`, an SgRoot) plus an `options` object
// ({ params, matches, matrixValues, dryRun, targetDir }) and returns:
// - a string → the new file contents
// - null → no change (return this when nothing matched, so unchanged files stay untouched)
//
// Keep it IDEMPOTENT: a second run over already-migrated code must return null. The pipeline's
// step 04 enforces this; design for it from the start (see the codemod rule
// `pattern-ensure-idempotency`).
import type { Codemod } from "codemod:ast-grep";
import type __LANGUAGE_TYPE__ from "codemod:ast-grep/langs/__LANGUAGE__";
const transform: Codemod<__LANGUAGE_TYPE__> = (root, options) => {
const rootNode = root.root();
// 1. FIND the nodes to change. Prefer a precise pattern with meta-variables ($NAME, $$$ARGS)
// over a broad one — patterns run on every node of every file (see `pattern-avoid-overly-generic`).
// Explore real shapes first: https://ast-grep.github.io/playground.html
const matches = rootNode.findAll({
rule: {
// EXAMPLE — replace with your pattern. This matches a named JSX element <FieldGroup ...>.
pattern: "<FieldGroup $$$PROPS>$$$CHILDREN</FieldGroup>",
},
});
if (matches.length === 0) return null; // idempotency + skips non-applicable files fast
// 2. BUILD one edit per match. Read captures with getMatch("NAME").text().
const edits = matches.map((node) => {
const props = node.getMatch("PROPS")?.text() ?? "";
const children = node.getMatch("CHILDREN")?.text() ?? "";
return node.replace(`<Fieldset ${props}>${children}</Fieldset>`);
});
// 3. COMMIT atomically. commitEdits returns the new source as a string.
const next = rootNode.commitEdits(edits);
// Optional: leave a marker for follow-up that verify.sh will flag (CODEMOD-TODO).
// return next.replace("// CODEMOD-TODO", "");
return next;
};
export default transform;
// Async variant (when an edit needs cross-file/semantic lookups):
//
// export default async function transform(
// root: SgRoot<__LANGUAGE_TYPE__>,
// ): Promise<string | null> { /* ... */ }
# __NAME__/workflow.yaml — outer-loop orchestration for a large-scale __LANGUAGE__ codemod.
# Generated by codemod-react-pipeline. Reference: https://docs.codemod.com/workflows/reference
#
# This is the codemod-CLI-native alternative to scripts/05-run-batched.sh. Use it when you want
# the run inside `codemod workflow run` (matrix fan-out, state, manual approval gate). Drive it:
#
# codemod workflow validate -w workflow.yaml
# codemod workflow run -w workflow.yaml -t . # or -t <subtree>
# codemod workflow status -i <run-id> # monitor
# codemod workflow resume -i <run-id> # resume after a failure
#
# IMPORTANT: per-step `commit:` checkpoints are a Campaign/cloud-only feature. For local OSS runs,
# commit from a `run:` git step (shown below) or use scripts/05-run-batched.sh, which commits
# per batch itself. See gotchas.md.
version: "1"
params:
schema:
target:
name: "Target directory"
description: "Sub-tree to transform"
type: string
default: "src"
state:
schema:
# Each shard becomes one parallel matrix task. Populate it however suits the repo
# (by team, by top-level dir, or by file batches). Example: top-level feature dirs.
- name: shards
type: array
items:
type: object
properties:
name: { type: string }
path: { type: string }
nodes:
# 1. Discover shards → write them into state (here: immediate subdirectories of the target).
- id: plan
name: Plan shards
type: automatic
steps:
- name: "Shard the target tree"
run: |
for d in $(find "${{ params.target }}" -mindepth 1 -maxdepth 1 -type d); do
echo "shards@={\"name\":\"$(basename "$d")\",\"path\":\"$d\"}"
done
# 2. Manual approval gate — nothing destructive runs until a human triggers this node.
# Mirrors the dry-run/validate gate enforced by the shell pipeline.
- id: approve
name: Approve mass apply (after dry-run + validation)
type: manual
depends_on: [plan]
steps:
- name: "Acknowledge"
run: echo "Approved apply for __NAME__ across ${{ params.target }}"
# 3. Fan out across shards. Each shard: apply → format → commit (local checkpoint).
- id: apply
name: Apply __NAME__ per shard
type: automatic
depends_on: [approve]
strategy:
type: matrix
from_state: shards
steps:
- name: "Run the JSSG transform on this shard"
js-ast-grep:
js_file: "transform.ts"
language: "__LANGUAGE__"
include: ["${{ matrix.path }}/**/*.{ts,tsx,js,jsx}"]
exclude: ["**/*.test.*", "**/*.spec.*", "**/*.d.ts"]
- name: "Format the shard"
run: npx prettier --write "${{ matrix.path }}/**/*.{ts,tsx,js,jsx}"
- name: "Verify the shard (typecheck)"
run: npx tsc --noEmit
- name: "Checkpoint commit (local — replaces cloud-only commit:)"
run: |
git add -A
git commit -q -m "refactor(__NAME__): ${{ matrix.name }} [codemod-react-pipeline]" || echo "nothing to commit for ${{ matrix.name }}"
{
"src_globs": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.js", "src/**/*.jsx"],
"language": "tsx",
"package_manager": "pnpm",
"typecheck_cmd": "pnpm exec tsc --noEmit",
"lint_cmd": "pnpm exec eslint",
"format_cmd": "pnpm exec prettier --check",
"test_cmd": "pnpm exec vitest run",
"batch_size": 500,
"max_threads": 0,
"state_dir": ".codemod-pipeline",
"gates": {
"idempotency": true,
"typecheck": true,
"lint": true,
"format": true,
"tests": true
},
"_setup_instructions": {
"src_globs": "Globs matching the files the codemod may touch (one per extension; passed to git as :(glob) pathspecs, which support ** across directories but NOT brace expansion). Used to count blast radius and scope lint/format/test gates. Narrow to the migration's real surface area.",
"language": "JSSG --language value for the dominant file type: 'tsx' for React + TypeScript, 'jsx' for React + JS, 'typescript'/'javascript' for non-React. Mixed trees: run the pipeline once per language.",
"package_manager": "pnpm | npm | yarn | bun. Used only to interpret the *_cmd fields below; set the *_cmd fields directly if your scripts differ.",
"typecheck_cmd": "Command that fails (non-zero) on a type error. Leave empty to disable. Scoped automatically to affected files where the tool supports it; otherwise runs project-wide.",
"lint_cmd": "ESLint invocation. The pipeline appends the affected file list. Leave empty to disable.",
"format_cmd": "Formatter check invocation (Prettier --check or equivalent). The pipeline appends the affected file list. Leave empty to disable.",
"test_cmd": "Unit test runner. The pipeline appends affected files / related patterns where the runner supports it. Leave empty to disable.",
"batch_size": "Files per batch in the outer loop. Each batch is verified and committed atomically. 200-1000 is typical; smaller = more checkpoints, larger = fewer commits.",
"max_threads": "Parallel threads for `codemod jssg run` (--max-threads). 0 = let codemod choose.",
"state_dir": "Directory (relative to the target repo root) where the pipeline records progress, the dry-run sentinel, and findings reports. Add it to .gitignore.",
"gates": "Toggle individual validation gates run by 04-validate-findings.sh and per batch in 05-run-batched.sh. Disable a gate only when the project genuinely lacks that tooling."
}
}
Gotchas
Failure points discovered building and running large React/TSX codemods with this pipeline. Add to this list as the field teaches you more.
Workflow commit: checkpoints are cloud-only
The commit: step option in workflow.yaml (commit: { message, add }) is a Codemod Campaign/cloud feature. A local codemod workflow run will not create those commits. This pipeline sidesteps it two ways: 05-run-batched.sh commits per batch itself, and the generated workflow.yaml checkpoints via an explicit run: git add -A && git commit step. Don't rely on commit: locally.
Placeholder syntax vs. Codemod interpolation
The scaffolder substitutes __NAME__-style tokens. Codemod's own templating uses ${{ matrix.x }} and ${{ state.x }}. They don't collide — but if you hand-edit templates, keep your placeholders in the __UPPER__ form and leave every ${{ … }} untouched.
Idempotency is on you, and it bites at scale
A transform that matches its own output keeps editing on every pass: re-runs never settle, parallel shards corrupt overlapping regions, and verify.sh will fail. Make the pattern unable to match the result (e.g. rewrite <FieldGroup>→<Fieldset>, never <X>→<X …>). Step 4's idempotency gate catches this before mass apply — don't disable it.
Dirty working tree blocks the pipeline (by design)
Reverting steps and checkpoints use git checkout -- .. With unrelated uncommitted changes present, that would discard your work, so the pipeline refuses to start dirty. Commit or git stash first. This is a feature, not a bug.
tsc --noEmit is project-wide, not file-scoped
TypeScript type-checks the whole program; you can't reliably check "just these 500 files." The typecheck gate therefore runs project-wide each batch — correct, but slower on big repos. For very large codebases, consider checking typecheck only at the end (verify.sh) and relying on lint+tests per batch, accepting that a type break surfaces later.
Embedded languages need a separate pass
GraphQL in gql\…\`, CSS in styled-components, SQL in template literals — the tsx parser sees these as plain template strings. A single transform won't reach inside them. Handle embedded languages with a dedicated parse pass (rule parse-handle-embedded-languages`); don't try to regex them from the TSX transform.
git ls-files only sees tracked files
The work list is built from git ls-files, so untracked new files aren't included (and .gitignore is respected). Usually what you want for a migration; if you must transform untracked files, add them first or pass --no-gitignore to codemod jssg run knowingly.
JSSG import names can drift between CLI versions
The transform template imports Codemod and langs/<lang> per https://docs.codemod.com/jssg/reference. If your installed codemod version exports Transform instead of Codemod, or a different langs path, adjust the import — the rest of the API (findAll/getMatch/replace/commitEdits) is stable.
xargs argument limits on huge affected sets
File-scoped gates pipe the affected list through xargs. A pathological batch can exceed the OS arg limit; lower batch_size so each batch's file list stays manageable.
The PreToolUse hook only guards this session
hooks/hooks.json is an on-demand hook active while the skill is in use. It is not a permanent safeguard on the repo — it won't protect a teammate running codemod jssg run from a fresh shell. Treat the dry-run discipline as a team convention, not just a hook.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/guardrail.sh\"",
"timeout": 5
}
]
}
]
}
}
{
"version": "0.1.0",
"discipline": "composition",
"type": "automation",
"organization": "Codemod Community",
"technology": "Codemod (JSSG, ast-grep, workflows)",
"date": "May 2026",
"abstract": "Guided, scripted pipeline for running JSX/TSX/React codemods safely across large legacy codebases. Walks a developer through the full inner/outer loop: define the transformation goal, scaffold a JSSG/ast-grep codemod, iterate in a tight inner loop with fixture tests, dry-run to inspect findings, validate findings against type-check/lint/test/idempotency gates, then apply at scale in resumable batches with per-batch checkpoint commits. Built for transformations that touch anywhere from 50 to several hundred thousand files. Pairs with the 'codemod' best-practices reference, which explains the why behind each step.",
"references": [
"https://docs.codemod.com/cli",
"https://docs.codemod.com/jssg/reference",
"https://docs.codemod.com/jssg/testing",
"https://docs.codemod.com/workflows/reference",
"https://github.com/codemod/codemod",
"https://ast-grep.github.io"
],
"category": "DevEx",
"relatedSkills": [
"codemod"
]
}
Codemod React Pipeline
A guided, scripted pipeline for running JSX / TSX / React codemods safely on large legacy codebases — from 50 files to several hundred thousand.
It composes the Codemod CLI (JSSG, ast-grep, workflows) into a repeatable inner/outer loop:
goal → scaffold → inner loop → dry-run → validate findings → batched apply → verifyThis is a composition skill (a workflow). For the why behind each transformation decision, see the sibling `codemod` best-practices reference.
Why a pipeline
A naive codemod jssg run ./transform.ts ./src on a large repo is a footgun: you discover the bug after 1,800 files changed, the diff is unreviewable, a mid-run crash forces a restart, and a non-idempotent transform corrupts files on the second pass. This pipeline removes those failure modes by gating mass application behind a dry-run and a set of validation checks, then applying in resumable, individually-verified, individually-committed batches.
Structure
codemod-react-pipeline/
├── SKILL.md # Entry point: workflow overview + triggers
├── metadata.json # discipline: composition, type: automation
├── config.json # Per-project setup (globs, language, gate commands, batch size)
├── gotchas.md # Failure points discovered in the field
├── scripts/
│ ├── lib/common.sh # Shared helpers (config, logging, git, affected files)
│ ├── 00-plan.sh # Goal intake + blast-radius estimate
│ ├── 01-scaffold.sh # codemod init + render templates
│ ├── 02-inner-loop.sh # Fixture tests + single-file run (tight loop)
│ ├── 03-dry-run.sh # Preview changes, write findings report + sentinel
│ ├── 04-validate-findings.sh # Idempotency / typecheck / lint / format / tests
│ ├── 05-run-batched.sh # Resumable batched apply with checkpoint commits
│ ├── verify.sh # Final assertions over the whole migration
│ ├── guardrail.sh # Hook backend: block mass apply without a dry-run
│ └── selftest.sh # Self-test for this skill's scripts
├── hooks/
│ └── hooks.json # PreToolUse guard, active during the skill session
├── assets/templates/ # transform.ts, ast-grep rule, workflow.yaml, fixtures, codemod.yaml
└── references/ # workflow.md, inner-outer-loop.md, safety-and-scale.mdRequirements
- `codemod` CLI —
npm i -g codemod(ornpx codemod) git— the pipeline relies on a clean working tree and per-batch commitsjq— used by the scripts to readconfig.json- Project tooling for the gates you enable (TypeScript, ESLint, Prettier, a test runner)
Quick start
cd /path/to/your/repo # the target codebase
SKILL=/path/to/codemod-react-pipeline
bash "$SKILL/scripts/00-plan.sh" "Replace <FieldGroup> with <Fieldset> across the app"
bash "$SKILL/scripts/01-scaffold.sh" rename-fieldgroup
# ...edit codemods/rename-fieldgroup/transform.ts + tests...
bash "$SKILL/scripts/02-inner-loop.sh" rename-fieldgroup
bash "$SKILL/scripts/03-dry-run.sh" rename-fieldgroup
bash "$SKILL/scripts/04-validate-findings.sh" rename-fieldgroup
bash "$SKILL/scripts/05-run-batched.sh" rename-fieldgroup
bash "$SKILL/scripts/verify.sh" rename-fieldgroupSee `SKILL.md` for the full workflow and `references/workflow.md` for per-step inputs, outputs, failure handling, and rollback.
Self-test
bash scripts/selftest.shChecks script syntax, strict-mode headers, hook JSON validity, and (if the codemod CLI is installed) validates the workflow template.
The Inner/Outer Loop Model
Large-scale codemods fail when people treat them as one big run. This pipeline splits the work into two loops with different goals, cadences, and blast radii.
The inner loop — make the transform correct (fast, local, reversible)
Goal: a transform that does the right thing on every shape it will meet. Cadence: seconds to minutes. Blast radius: fixtures and one real file at a time.
edit transform.ts ─▶ jssg test (fixtures) ─▶ trial on 1 real file ─▶ inspect diff ─▶ revert ─▶ repeat- Drive it with fixtures, not the codebase. Each
tests/<case>/{input,expected}pair pins one
behaviour. New surprise in a real file → add a fixture, never "fix it once by hand."
- Explore the AST before writing patterns. Node kinds and field names are not guessable; use
https://ast-grep.github.io/playground.html (rule ast-explore-before-writing).
- Keep the diff between input and expected minimal — same names, same structure — so the
fixture documents exactly one transformation.
- `02-inner-loop.sh --watch` keeps the loop tight; `--file <path>` sanity-checks against
real code and auto-reverts so you never accumulate stray edits.
You leave the inner loop when fixtures cover the known edge cases (happy path, already-migrated, nested, spread props, …) and a trial on a real file looks right.
The outer loop — apply it safely at scale (gated, batched, committed)
Goal: land the change across the whole codebase with the build green at every step and a clean rollback story. Cadence: minutes to hours. Blast radius: the whole src_globs set.
dry-run (preview findings) ─▶ validate (gates) ─▶ batched apply (per-batch verify + commit) ─▶ verify- Dry-run first, always. You inspect what would change and how many files before any write
(rule test-run-on-subset-first). The pipeline enforces this with a sentinel + a PreToolUse hook.
- Validate the findings, don't trust them. Idempotency, typecheck, lint, format, and tests run
on the would-be result. Idempotency is the headline gate (rule pattern-ensure-idempotency).
- Batch + checkpoint. Apply in chunks, verify each, commit each. A mid-run failure costs one
batch, not the whole migration, and the run resumes (rule state-use-for-resumability).
- Verify the end state, not just exit codes: re-running the codemod must be a no-op.
How the loops connect
The inner loop produces an artifact (the transform + fixtures); the outer loop consumes it. The dry-run sentinel is the handshake: the outer loop's apply step refuses to run until a dry-run has happened, which in practice means the transform left the inner loop and was previewed.
Picking the engine in the inner loop
| Transformation shape | Engine | Why |
|---|---|---|
| One pattern → one rewrite, no logic | declarative ast-grep `rule.yml` | Fast, deterministic, least to get wrong |
| Conditional edits, derived names, many edits/match | JSSG `transform.ts` | Full programmatic control |
| Needs imports / symbol resolution / types | JSSG transform.ts (+ import fixups, optional ai: cleanup step) | Cross-file awareness |
When in doubt, start with a rule; promote to a transform the moment you reach for an if.
Scale tiers (set expectations for the outer loop)
| Candidate files | Tier | Outer-loop posture |
|---|---|---|
| < 200 | small | Single batch is fine — still dry-run + validate first |
| 200–5k | medium | Batched recommended; per-batch verify catches cross-file breakage early |
| 5k–50k | large | Batched + per-batch verify mandatory; expect to resume at least once |
| > 50k | very large | Batched + resumable + parallel (max_threads/workflow matrix); plan for multi-hour, multi-session runs |
Safety & Scale
How this pipeline keeps a 100k-file codemod from becoming a 100k-file incident — and how to roll back when something still slips through.
The four safety layers
1. Dry-run before any write. 03-dry-run.sh previews the full change set and reverts. You read findings.txt (count + affected files + sample diff) before committing to anything. 2. A sentinel + a hook enforce the dry-run. 05-run-batched.sh refuses to run without dry-run.ok. hooks/hooks.json additionally blocks an ad-hoc broad codemod jssg run that lacks --dry-run when no sentinel exists — so the gate can't be skipped by typing the command manually. 3. Validation gates. 04-validate-findings.sh proves idempotency and runs typecheck/lint/ format/tests on the would-be result. Per-batch gates in step 5 re-check each chunk. 4. Per-batch checkpoint commits + clean-tree precondition. Every batch is isolated and reversible; the pipeline starts only from a clean tree so any revert is exact.
Batching strategy
05-run-batched.sh splits src_globs into batch_size-file batches. Each batch is applied, verified, and committed atomically; progress.tsv records completion so re-running resumes.
Tuning batch_size:
- Smaller (100–250): more checkpoints, finer rollback granularity, more commits, more gate
runs (slower overall). Good for risky transforms or flaky test suites.
- Larger (1000+): fewer commits, faster, coarser rollback. Good for mechanical, well-tested
transforms.
Batches are file-ordered by default. If your transform has cross-file effects (renaming an export consumed elsewhere), order won't keep related files together — use the workflow.yaml matrix to shard by module/team directory instead, so a shard is internally consistent and its typecheck gate is meaningful.
Parallelism
- JSSG:
config.max_threads→codemod jssg run --max-threads N(per-batch internal parallelism). - Workflow:
strategy: { type: matrix, from_state: shards }fans shards out concurrently. Each
shard commits independently — ideal for branch/PR-per-module review at scale.
Parallelism multiplies a non-idempotent or non-deterministic transform's damage. Pass step 4's idempotency gate before turning threads up.
Resumability
State lives in <state_dir>/<name>/:
dry-run.ok— the gate sentinelfiles.txt— the frozen work list (built once, reused on resume)progress.tsv—<batch-index>\tok\t<sha>per completed batch
To resume after any interruption, just re-run 05-run-batched.sh <name> — completed batches are skipped. To start over, delete the state dir.
The codemod CLI has its own run-level resume: codemod workflow resume -i <run-id> (andworkflow status -i <run-id> to inspect). Use that when you drive the migration throughworkflow run instead of the batch script.Rollback playbook
| Situation | Action |
|---|---|
| One batch looks wrong, not pushed | git reset --hard <sha>^ (sha from progress.tsv) |
| One batch looks wrong, already pushed | git revert <sha> |
| Whole migration, not pushed | git log --oneline --grep='\[codemod-react-pipeline\]', then git reset --hard <first-sha>^ |
| Whole migration, already pushed | git revert <oldest>..<newest> over the tagged range |
| Mid-inner-loop stray edits | git checkout -- . (inner loop should auto-revert, but this is the safety net) |
All checkpoint commits carry [codemod-react-pipeline] in the message, so the range is always greppable.
Excluding the wrong files
The fastest way to a bad large-scale run is touching files you didn't mean to:
- Narrow
src_globsto the real surface area. - Add
excludeglobs inworkflow.yamlfor tests, type decls, generated, and vendored code
(**/*.test.*, **/*.d.ts, **/__generated__/**, **/vendor/**).
- The pipeline respects
.gitignoreby default (it lists files viagit ls-files); `codemod jssg
run does too unless --no-gitignore` is passed.
Capabilities (least privilege)
JSSG is deny-by-default. Grant only the capabilities the transform truly needs in codemod.yaml (rule security-minimize-capabilities). A pure AST rewrite needs none; reading sibling files for context needs fs; nothing should need network. Review third-party codemods before running them (security-review-before-running-third-party).
Codemod React Pipeline — Workflow Reference
Per-step documentation: what each step needs, produces, how it fails, and how to undo it. Run all scripts from the root of the codebase you are transforming (not the skill directory).
Prerequisites
codemodCLI installed (ornpx codemodreachable),git,jq.- A clean git working tree (the pipeline refuses to run otherwise — see "Why clean tree" below).
config.jsonfilled in for this repo.
---
Step 0 — 00-plan.sh "<goal>" [name]
Action: Records the goal, counts files matching src_globs (blast radius), suggests a scale tier, and writes a plan.md checklist that forces a classification decision (syntactic vs programmatic vs semantic). Input: A one-sentence goal. Optional kebab-case codemod name (derived from the goal otherwise). Output: <state_dir>/<name>/plan.md. On failure: Usually "not a git repo" or "jq missing" — install/cd and retry. Zero candidate files means src_globs is wrong. Rollback: N/A — read-only (writes only into the ignored state dir).
---
Step 1 — 01-scaffold.sh <name> [--rule] [--force]
Action: Renders codemods/<name>/ from templates: transform.ts (or rule.yml with --rule), a tests/basic/{input,expected}.<ext> fixture pair, workflow.yaml, and codemod.yaml. Input: Codemod name; --rule for declarative ast-grep; --force to overwrite. Output: The scaffolded codemod project. On failure: Exits 2 if the codemod already exists (use --force). Bad name → kebab-case error. Rollback: rm -rf codemods/<name> (nothing else is touched).
Choosing the engine: declarativerule.ymlfor pure pattern→rewrite;transform.ts(JSSG) the
moment you need conditionals, derived names, or multiple edits per match. See
inner-outer-loop.md.
---
Step 2 — 02-inner-loop.sh <name> [--watch | --file <path> | -u]
Action: The tight loop. Default runs codemod jssg test against your fixtures. --watch re-runs on change. --file <path> does a trial jssg run on one real file, prints the diff, then reverts it (the inner loop never keeps edits). -u updates fixture snapshots (intentional only). Input: Working fixtures and a transform under edit. Output: Pass/fail test report, or a single-file diff. On failure: Failing fixtures print the expected/actual diff — fix the transform or the fixture. "No change produced" on --file means the pattern isn't matching: explore the AST (https://ast-grep.github.io/playground.html). Rollback: N/A — --file auto-reverts; tests never modify the tree.
Promote any real-file surprise into a new fixture case dir so it's covered forever.
---
Step 3 — 03-dry-run.sh <name> [--target <dir>] [--sample N]
Action: Previews the codemod across the codebase without keeping changes. Captures an exact diff and an affected-file list, writes a findings report, then reverts the tree. On success writes the dry-run sentinel that step 5 requires. Input: A clean tree; a working transform. Output: <state_dir>/<name>/findings.txt, dry-run.diff, and dry-run.ok (sentinel). On failure: 0 changes → the sentinel is not written and it exits non-zero (the transform isn't matching; return to step 2). A dirty tree is rejected so the captured diff is purely the codemod's doing. Rollback: Automatic — the tree is restored before the script returns.
--sample N previews a random N-file subset for a fast read on huge repos (cites test-run-on-subset-first).
---
Step 4 — 04-validate-findings.sh <name>
Action: Applies the codemod to the clean tree, runs the enabled gates, then reverts. Nothing is committed. Gates: idempotency (apply twice → no further change), typecheck, lint, format, tests (the file-scoped gates receive the affected file list). Input: Clean tree; gate commands set in config.json. Output: Per-gate PASS/FAIL summary; gate logs in the state dir. On failure: Exits non-zero and names the failing gate + log. Fix the transform or the gate config; do not proceed to step 5. Rollback: Automatic (an EXIT trap restores the tree even on error).
Idempotency failure is the most common and most important catch — it means a second pass keeps editing (e.g. the pattern matches its own output). Fix per pattern-ensure-idempotency.
---
Step 5 — 05-run-batched.sh <name> [--batch-size N] [--dry] [--resume]
Action: The mass apply. Builds the file list, splits into batches of batch_size, and for each batch: apply → per-batch gates (typecheck/lint/tests) → git commit. Progress is recorded in progress.tsv; re-running skips completed batches (resumable). Requires the dry-run sentinel. Input: Passing step 4; clean tree; sentinel present. Output: One checkpoint commit per non-empty batch (message tagged [codemod-react-pipeline]); updated progress.tsv. On failure: The failing batch is reverted (its commit is never made), the script stops, and the log path is printed. Fix, then re-run to resume from that batch — earlier commits stand. Rollback:
- Undo one batch:
git revert <sha>(orgit reset --hard <sha>^if not pushed). - Undo everything:
git log --oneline --grep='\[codemod-react-pipeline\]'then reset/revert the range.
--dry prints the batch plan and changes nothing. --batch-size overrides the config value.
The codemod-native alternative is codemod workflow run -w codemods/<name>/workflow.yaml withmatrix sharding + a manual approval gate. Use it when you want the run orchestrated by the CLI;
note the cloud-only commit: caveat in gotchas.md.---
Step 6 — verify.sh <name>
Action: Final sign-off. Asserts: every batch recorded complete (none failed); re-running the codemod is a no-op (migration complete + idempotent); the project type-checks; no CODEMOD-TODO markers remain. Input: A completed, committed migration. Output: Pass/fail assertion summary. On failure: Names the failed assertion. A non-no-op re-run means files were missed — re-run step 5; leftover markers mean manual follow-ups are outstanding. Rollback: N/A — read-only (reverts its probe re-application).
---
Why a clean tree
Every reverting step (03, 04) and every checkpoint (05) relies on git checkout -- . to undo the codemod's edits. If the tree already had unrelated changes, that undo would also discard your work. The pipeline refuses to start dirty so rollback is always exact. Stash or commit first.
Troubleshooting
"No dry-run on record for '<name>'"
Cause: Step 5 (or the hook) found no dry-run.ok sentinel. Fix: Run 03-dry-run.sh <name> and 04-validate-findings.sh <name> first.
Dry-run shows 0 changes
Cause: The pattern doesn't match real code. Fix: Open the AST playground, compare your pattern to the actual node kinds, widen/narrow meta-variables, re-test fixtures (step 2).
A gate passes locally but fails in a batch
Cause: Cross-file effects (a batch breaks a file in another batch). Fix: Re-shard so related files land together (workflow.yaml matrix by module), or run a project-wide typecheck gate.
xargs "argument list too long" on a huge affected set
Cause: Too many files for one command. Fix: Lower batch_size; the per-batch gates then run on smaller file lists.
#!/usr/bin/env bash
# 00-plan.sh — turn a transformation goal into a written plan + blast-radius estimate.
# Part of: codemod-react-pipeline (inner loop, step 0)
#
# Run from the root of the codebase you want to transform.
#
# Usage: bash 00-plan.sh "<goal>" [codemod-name]
# Example: bash 00-plan.sh "Replace <FieldGroup> with <Fieldset>" rename-fieldgroup
#
# Output: <state_dir>/<name>/plan.md — a checklist you fill in before scaffolding.
# Exit: 0 = plan written, 1 = error.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib/common.sh"
# --- Input validation ------------------------------------------------------
if [[ $# -lt 1 || -z "${1:-}" ]]; then
echo "Usage: $0 \"<goal>\" [codemod-name]" >&2
echo "" >&2
echo " <goal> One sentence describing the transformation." >&2
echo " codemod-name kebab-case id for the codemod (default: derived from goal)." >&2
exit 1
fi
need_cmd git "Run this from inside the target git repository."
need_cmd jq "Install jq (e.g. 'brew install jq') — the pipeline reads config.json with it."
GOAL="$1"
NAME="${2:-}"
if [[ -z "$NAME" ]]; then
# Derive a kebab-case name from the goal: lowercase, alnum→-, trim, cap length.
NAME="$(printf '%s' "$GOAL" | tr '[:upper:]' '[:lower:]' \
| sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' | cut -c1-40)"
[[ -n "$NAME" ]] || NAME="codemod-$(date +%s)"
fi
LANGUAGE="$(config_get '.language' 'tsx')"
STATE="$(state_dir_for "$NAME")"
PLAN="$STATE/plan.md"
log_step "Planning codemod '$NAME' (language: $LANGUAGE)"
# --- Blast radius ----------------------------------------------------------
CANDIDATES="$(count_candidates)"
log_info "Candidate files matching config.src_globs: $CANDIDATES"
if [[ "$CANDIDATES" -eq 0 ]]; then
log_warn "No files matched src_globs. Check 'src_globs' in config.json before continuing."
fi
# Suggest a tier so the dev knows how much ceremony the outer loop needs.
TIER="small"
if [[ "$CANDIDATES" -ge 50000 ]]; then TIER="very-large (batched, resumable, expect multi-hour)"
elif [[ "$CANDIDATES" -ge 5000 ]]; then TIER="large (batched + per-batch verify)"
elif [[ "$CANDIDATES" -ge 200 ]]; then TIER="medium (batched recommended)"
else TIER="small (single batch is fine, still dry-run first)"; fi
# --- Write the plan --------------------------------------------------------
cat > "$PLAN" <<EOF
# Codemod plan: $NAME
> Generated $(date -u +%Y-%m-%dT%H:%M:%SZ) by codemod-react-pipeline / 00-plan.sh
## Goal
$GOAL
## Blast radius
- Candidate files (config.src_globs): **$CANDIDATES**
- Scale tier: **$TIER**
- Language: \`$LANGUAGE\`
## Classify the transformation (decides the engine — see references/inner-outer-loop.md)
- [ ] **Syntactic** — pure pattern → replacement, no type/scope info needed.
Prefer a declarative **ast-grep rule** (\`01-scaffold.sh $NAME --rule\`). Fast, deterministic.
- [ ] **Programmatic** — conditional logic, derived names, multiple edits per match.
Use a **JSSG transform.ts** (\`01-scaffold.sh $NAME\`).
- [ ] **Semantic / cross-file** — needs imports, symbol resolution, type info.
JSSG transform.ts + plan for import fixups; consider an \`ai:\` cleanup step in workflow.yaml.
## Safety pre-checks
- [ ] Is the transform **idempotent**? (running twice must not double-apply) — gate enforced in step 04.
- [ ] Are there **generated / vendored** dirs to exclude? Tighten \`src_globs\` / add \`exclude\`.
- [ ] Any **embedded languages** (e.g. GraphQL in template literals)? They need a separate pass.
- [ ] Minimum **capabilities** the codemod needs (fs, network)? Keep them minimal.
## Edge cases to cover with fixtures (step 02)
- [ ] Happy path
- [ ] Already-migrated file (idempotency)
- [ ] (add the tricky shapes you know exist in this codebase)
## Next
\`\`\`bash
bash $SCRIPT_DIR/01-scaffold.sh $NAME # JSSG transform, or
bash $SCRIPT_DIR/01-scaffold.sh $NAME --rule # declarative ast-grep rule
\`\`\`
EOF
log_ok "Plan written: ${PLAN#"$(target_root)"/}"
echo ""
echo "Review the plan, tick the classification, then scaffold:" >&2
echo " bash $SCRIPT_DIR/01-scaffold.sh $NAME" >&2
#!/usr/bin/env bash
# 01-scaffold.sh — scaffold a codemod project from the pipeline templates.
# Part of: codemod-react-pipeline (inner loop, step 1)
#
# Creates codemods/<name>/ in the target repo with:
# - transform.ts (JSSG) OR rule.yml (declarative ast-grep, with --rule)
# - tests/basic/{input,expected}.<ext> fixture pair (TDD for the transform)
# - workflow.yaml outer-loop orchestration (matrix sharding + checkpoints)
# - codemod.yaml package metadata
#
# Usage: bash 01-scaffold.sh <name> [--rule] [--force]
# Exit: 0 = scaffolded, 1 = error, 2 = already exists (use --force to overwrite).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib/common.sh"
# --- Input validation ------------------------------------------------------
NAME=""; MODE="jssg"; FORCE=0
for arg in "$@"; do
case "$arg" in
--rule) MODE="rule" ;;
--force) FORCE=1 ;;
-*) die "Unknown flag: $arg" ;;
*) [[ -z "$NAME" ]] && NAME="$arg" || die "Unexpected argument: $arg" ;;
esac
done
[[ -n "$NAME" ]] || { echo "Usage: $0 <name> [--rule] [--force]" >&2; exit 1; }
[[ "$NAME" =~ ^[a-z0-9][a-z0-9-]*$ ]] || die "Name must be kebab-case: '$NAME'"
need_cmd git
need_cmd jq
LANGUAGE="$(config_get '.language' 'tsx')"
case "$LANGUAGE" in
tsx) EXT="tsx"; LANG_TYPE="TSX" ;;
jsx) EXT="jsx"; LANG_TYPE="JSX" ;;
typescript) EXT="ts"; LANG_TYPE="TypeScript" ;;
javascript) EXT="js"; LANG_TYPE="JavaScript" ;;
*) EXT="$LANGUAGE"; LANG_TYPE="$(printf '%s' "$LANGUAGE" | tr '[:lower:]' '[:upper:]')" ;;
esac
PROJ="$(codemod_proj_dir "$NAME")"
if [[ -d "$PROJ" && $FORCE -ne 1 ]]; then
skip "Codemod '$NAME' already exists at ${PROJ#"$(target_root)"/}. Re-run with --force to overwrite."
fi
log_step "Scaffolding codemod '$NAME' ($MODE, language: $LANGUAGE)"
mkdir -p "$PROJ/tests/basic"
# render <template> <dest> — substitute __TOKENS__ and write the file.
render() {
local tpl="$1" dest="$2"
[[ -f "$tpl" ]] || die "Missing template: $tpl"
sed -e "s/__NAME__/$NAME/g" \
-e "s/__LANGUAGE_TYPE__/$LANG_TYPE/g" \
-e "s/__LANGUAGE__/$LANGUAGE/g" \
-e "s/__EXT__/$EXT/g" \
"$tpl" > "$dest"
}
T="$PIPELINE_TEMPLATES_DIR"
if [[ "$MODE" == "rule" ]]; then
render "$T/astgrep-rule.yml.template" "$PROJ/rule.yml"
log_ok "rule.yml"
else
render "$T/transform.ts.template" "$PROJ/transform.ts"
log_ok "transform.ts"
fi
render "$T/fixtures/input.tsx.template" "$PROJ/tests/basic/input.$EXT"
render "$T/fixtures/expected.tsx.template" "$PROJ/tests/basic/expected.$EXT"
render "$T/workflow.yaml.template" "$PROJ/workflow.yaml"
render "$T/codemod.yaml.template" "$PROJ/codemod.yaml"
log_ok "tests/basic/{input,expected}.$EXT, workflow.yaml, codemod.yaml"
echo ""
log_step "Next: implement the transform, then iterate"
echo " 1. Explore the AST: https://ast-grep.github.io/playground.html" >&2
if [[ "$MODE" == "rule" ]]; then
echo " 2. Edit the rule: ${PROJ#"$(target_root)"/}/rule.yml" >&2
else
echo " 2. Edit the transform: ${PROJ#"$(target_root)"/}/transform.ts" >&2
fi
echo " 3. Edit fixtures: ${PROJ#"$(target_root)"/}/tests/basic/{input,expected}.$EXT" >&2
echo " 4. Tight loop: bash $SCRIPT_DIR/02-inner-loop.sh $NAME" >&2
#!/usr/bin/env bash
# 02-inner-loop.sh — the tight feedback loop: fixture tests + a single-file trial run.
# Part of: codemod-react-pipeline (inner loop, step 2)
#
# Run this repeatedly while writing the transform. It does NOT touch the wider codebase.
#
# Usage:
# bash 02-inner-loop.sh <name> # run fixture tests once
# bash 02-inner-loop.sh <name> --watch # re-run fixture tests on change
# bash 02-inner-loop.sh <name> --file <path> # trial-run on ONE real file, show diff, revert
# bash 02-inner-loop.sh <name> -u # update fixture snapshots (intentional)
#
# Exit: 0 = tests pass / trial done, 1 = error or failing tests.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib/common.sh"
NAME=""; WATCH=0; UPDATE=0; ONE_FILE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--watch) WATCH=1 ;;
-u|--update-snapshots) UPDATE=1 ;;
--file) shift; ONE_FILE="${1:-}"; [[ -n "$ONE_FILE" ]] || die "--file needs a path" ;;
-*) die "Unknown flag: $1" ;;
*) [[ -z "$NAME" ]] && NAME="$1" || die "Unexpected argument: $1" ;;
esac
shift
done
[[ -n "$NAME" ]] || { echo "Usage: $0 <name> [--watch] [--file <path>] [-u]" >&2; exit 1; }
need_cmd git
need_cmd jq
require_codemod "$NAME"
LANGUAGE="$(config_get '.language' 'tsx')"
PROJ="$(codemod_proj_dir "$NAME")"
CM="$(codemod_cli)"
# A declarative rule (rule.yml) is run via ast-grep, not jssg test.
if [[ -f "$PROJ/rule.yml" && ! -f "$PROJ/transform.ts" ]]; then
log_step "Declarative rule detected — validating with ast-grep"
if command -v ast-grep >/dev/null 2>&1; then
ast-grep scan --rule "$PROJ/rule.yml" "$(target_root)" || true
log_info "Review matches above. ast-grep has no fixture runner; rely on dry-run (step 03) to confirm."
else
log_warn "ast-grep not installed. Run dry-run (03) to preview rule matches via the codemod CLI."
fi
exit 0
fi
TRANSFORM="$PROJ/transform.ts"
[[ -f "$TRANSFORM" ]] || die "No transform.ts in ${PROJ#"$(target_root)"/}. Scaffold first (01-scaffold.sh $NAME)."
# --- Single-file trial run (no commit, auto-revert) ------------------------
if [[ -n "$ONE_FILE" ]]; then
[[ -f "$ONE_FILE" ]] || die "File not found: $ONE_FILE"
require_clean_git
log_step "Trial run on a single file: $ONE_FILE"
# shellcheck disable=SC2086
$CM jssg run "$TRANSFORM" "$ONE_FILE" --language "$LANGUAGE"
echo "" >&2
if git -C "$(target_root)" diff --quiet -- "$ONE_FILE"; then
log_warn "No change produced on this file. Is the pattern matching? Check the AST playground."
else
log_step "Diff:"
git -C "$(target_root)" --no-pager diff -- "$ONE_FILE" >&2 || true
log_step "Reverting trial change (inner loop never keeps edits)"
git -C "$(target_root)" checkout -- "$ONE_FILE"
log_ok "Reverted. Promote to a fixture if this case matters: tests/<case>/{input,expected}.*"
fi
exit 0
fi
# --- Fixture tests ---------------------------------------------------------
ARGS=(jssg test "$TRANSFORM" --language "$LANGUAGE")
[[ $UPDATE -eq 1 ]] && ARGS+=(--update-snapshots)
[[ $WATCH -eq 1 ]] && ARGS+=(--watch)
if [[ $UPDATE -eq 1 ]]; then
log_warn "Updating fixture snapshots — only do this when the new output is intentional."
fi
log_step "Running fixture tests for '$NAME'"
# shellcheck disable=SC2086
exec $CM "${ARGS[@]}"
#!/usr/bin/env bash
# 03-dry-run.sh — preview the codemod across the codebase WITHOUT writing files.
# Part of: codemod-react-pipeline (outer loop, step 3 — the safety gate before any apply)
#
# Produces a findings report (how many files would change, which ones, sample diffs) and writes
# the dry-run sentinel that 05-run-batched.sh requires before it will touch files at scale.
#
# Usage:
# bash 03-dry-run.sh <name> # dry-run over config.src_globs target dir
# bash 03-dry-run.sh <name> --target <dir> # restrict to a sub-tree (e.g. a sample)
# bash 03-dry-run.sh <name> --sample <N> # dry-run a random N-file sample (fast on huge repos)
#
# Exit: 0 = dry-run completed (sentinel written), 1 = error.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib/common.sh"
NAME=""; TARGET=""; SAMPLE=0
while [[ $# -gt 0 ]]; do
case "$1" in
--target) shift; TARGET="${1:-}"; [[ -n "$TARGET" ]] || die "--target needs a path" ;;
--sample) shift; SAMPLE="${1:-0}"; [[ "$SAMPLE" =~ ^[0-9]+$ ]] || die "--sample needs a number" ;;
-*) die "Unknown flag: $1" ;;
*) [[ -z "$NAME" ]] && NAME="$1" || die "Unexpected argument: $1" ;;
esac
shift
done
[[ -n "$NAME" ]] || { echo "Usage: $0 <name> [--target <dir>] [--sample <N>]" >&2; exit 1; }
need_cmd git; need_cmd jq
require_codemod "$NAME"
LANGUAGE="$(config_get '.language' 'tsx')"
PROJ="$(codemod_proj_dir "$NAME")"
STATE="$(state_dir_for "$NAME")"
ROOT="$(target_root)"
CM="$(codemod_cli)"
REPORT="$STATE/findings.txt"
DIFF="$STATE/dry-run.diff"
# Resolve the transform/rule and the CLI invocation.
if [[ -f "$PROJ/transform.ts" ]]; then
RUN=("jssg" "run" "$PROJ/transform.ts")
elif [[ -f "$PROJ/rule.yml" ]]; then
# ast-grep rules are applied through the codemod workflow/ast-grep step; dry-run via ast-grep if present.
RUN=()
else
die "No transform.ts or rule.yml in ${PROJ#"$ROOT"/}. Scaffold first."
fi
# --- Build the dry-run diff -------------------------------------------------
log_step "Dry-running '$NAME' (no files will be modified)"
require_clean_git # so the diff we capture is purely the codemod's doing
cleanup() { :; }
trap cleanup EXIT
if [[ ${#RUN[@]} -gt 0 ]]; then
# JSSG path: --dry-run prints would-be changes; we also derive a real diff on a temp checkout
# so we can count files reliably across CLI versions.
TARGET_DIR="${TARGET:-$ROOT}"
if [[ "$SAMPLE" -gt 0 ]]; then
log_info "Sampling $SAMPLE files for a fast preview"
mapfile -t FILES < <(list_candidates | shuf | head -n "$SAMPLE")
[[ ${#FILES[@]} -gt 0 ]] || die "No candidate files to sample. Check config.src_globs."
# Apply for real, capture diff, then revert — bounded to the sample only.
# shellcheck disable=SC2086
$CM "${RUN[@]}" "${FILES[@]/#/$ROOT/}" --language "$LANGUAGE" >/dev/null 2>&1 || true
else
# Try the native --dry-run first (no writes); fall back to apply+diff+revert if unsupported.
# shellcheck disable=SC2086
if $CM "${RUN[@]}" "$TARGET_DIR" --language "$LANGUAGE" --dry-run > "$STATE/dry-run.raw" 2>&1; then
log_info "Captured native --dry-run output ($STATE/dry-run.raw)"
fi
# Apply for real to compute an exact diff, then revert everything.
# shellcheck disable=SC2086
$CM "${RUN[@]}" "$TARGET_DIR" --language "$LANGUAGE" >/dev/null 2>&1 || true
fi
else
log_warn "Declarative rule: previewing matches via ast-grep (install ast-grep for richer output)."
command -v ast-grep >/dev/null 2>&1 \
&& ast-grep scan --rule "$PROJ/rule.yml" "$ROOT" > "$STATE/dry-run.raw" 2>&1 || true
# Apply rule for real to diff, then revert.
command -v ast-grep >/dev/null 2>&1 \
&& ast-grep scan --rule "$PROJ/rule.yml" --update-all "$ROOT" >/dev/null 2>&1 || true
fi
# --- Capture findings, then revert ALL changes -----------------------------
git -C "$ROOT" --no-pager diff > "$DIFF" || true
mapfile -t CHANGED < <(git -C "$ROOT" diff --name-only --diff-filter=ACMR || true)
N_CHANGED=${#CHANGED[@]}
log_step "Reverting dry-run edits (working tree returns to clean)"
git -C "$ROOT" checkout -- . 2>/dev/null || true
git -C "$ROOT" clean -fd -- "$(config_get '.state_dir' '.codemod-pipeline')" >/dev/null 2>&1 || true
# --- Write findings report -------------------------------------------------
{
echo "# Dry-run findings: $NAME"
echo "Generated $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo ""
echo "Files that WOULD change: $N_CHANGED"
echo "Diff: ${DIFF#"$ROOT"/}"
echo ""
echo "## Affected files"
printf '%s\n' "${CHANGED[@]:-}(none)"
echo ""
echo "## Sample diff (first 120 lines)"
head -n 120 "$DIFF" 2>/dev/null || echo "(empty)"
} > "$REPORT"
echo "" >&2
if [[ "$N_CHANGED" -eq 0 ]]; then
log_warn "Dry-run produced 0 changes. The transform may not be matching — revisit step 02."
log_warn "Sentinel NOT written; fix the transform before applying."
exit 1
fi
# Sentinel records what was previewed so the apply step can sanity-check scope.
cat > "$(dry_run_sentinel "$NAME")" <<EOF
codemod=$NAME
dry_run_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
files_changed=$N_CHANGED
sample=${SAMPLE}
target=${TARGET:-<all>}
EOF
log_ok "Dry-run complete: $N_CHANGED file(s) would change."
log_ok "Findings: ${REPORT#"$ROOT"/}"
echo "" >&2
echo "Inspect the findings, then validate them:" >&2
echo " less ${REPORT#"$ROOT"/}" >&2
echo " bash $SCRIPT_DIR/04-validate-findings.sh $NAME" >&2
#!/usr/bin/env bash
# 04-validate-findings.sh — prove the transform is safe before applying it at scale.
# Part of: codemod-react-pipeline (outer loop, step 4 — the validation gate)
#
# Applies the codemod to a clean working tree, runs the configured gates against the result,
# then reverts. Nothing is committed. A failure here means: do NOT proceed to step 05.
#
# Gates (toggle in config.json .gates):
# idempotency — applying twice yields no further change
# typecheck — config.typecheck_cmd succeeds
# lint — config.lint_cmd succeeds on affected files
# format — config.format_cmd succeeds on affected files
# tests — config.test_cmd succeeds (affected files appended where supported)
#
# Usage: bash 04-validate-findings.sh <name>
# Exit: 0 = all enabled gates pass, 1 = a gate failed / error.
set -uo pipefail # not -e: run all gates, tally failures, always revert
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib/common.sh"
NAME="${1:-}"
[[ -n "$NAME" ]] || { echo "Usage: $0 <name>" >&2; exit 1; }
need_cmd git; need_cmd jq
require_codemod "$NAME"
ROOT="$(target_root)"
STATE="$(state_dir_for "$NAME")"
STATE_BASE="$(config_get '.state_dir' '.codemod-pipeline')"
require_clean_git
PASS=0; FAIL=0; SKIP=0
gate_pass() { echo "${_C_GRN} PASS${_C_RESET} $*" >&2; PASS=$((PASS+1)); }
gate_fail() { echo "${_C_RED} FAIL${_C_RESET} $*" >&2; FAIL=$((FAIL+1)); }
gate_skip() { echo "${_C_DIM} skip${_C_RESET} $*" >&2; SKIP=$((SKIP+1)); }
# Always restore the working tree, even on error. Exclude the state dir so the dry-run sentinel
# and gate logs survive the clean (robust even if the user hasn't .gitignored it).
restore() {
git -C "$ROOT" checkout -- . 2>/dev/null || true
git -C "$ROOT" clean -fdq -e "$STATE_BASE" -- . 2>/dev/null || true
}
trap restore EXIT
log_step "Applying '$NAME' to a clean tree for validation"
if ! codemod_apply "$NAME" "$ROOT" >/dev/null 2>&1; then
die "Codemod failed to run. Fix it in the inner loop (02) before validating."
fi
mapfile -t AFFECTED < <(affected_files)
N=${#AFFECTED[@]}
if [[ "$N" -eq 0 ]]; then
die "Transform produced no changes on a clean tree. Nothing to validate — revisit step 02/03."
fi
log_ok "$N file(s) changed; running gates"
echo "" >&2
# --- Gate: idempotency -----------------------------------------------------
if gate_enabled idempotency; then
first="$(git -C "$ROOT" diff | git hash-object --stdin 2>/dev/null || echo first)"
codemod_apply "$NAME" "$ROOT" >/dev/null 2>&1 || true
second="$(git -C "$ROOT" diff | git hash-object --stdin 2>/dev/null || echo second)"
if [[ "$first" == "$second" ]]; then
gate_pass "idempotency (second application changed nothing)"
else
gate_fail "idempotency — applying twice keeps changing files. Add a guard so matched code is skipped once transformed (see codemod rule 'pattern-ensure-idempotency')."
fi
else
gate_skip "idempotency (disabled in config)"
fi
# Build a NUL-safe affected-file list for the file-scoped gates.
printf '%s\0' "${AFFECTED[@]}" > "$STATE/affected.0"
# run_gate <label> <enabled-name> <cmd-config-path> <append-files:yes|no>
run_gate() {
local label="$1" name="$2" cfgpath="$3" append="$4" cmd
if ! gate_enabled "$name"; then gate_skip "$label (disabled in config)"; return; fi
cmd="$(config_get "$cfgpath" '')"
if [[ -z "$cmd" ]]; then gate_skip "$label (no command configured)"; return; fi
log_info "$label: $cmd"
local rc=0
if [[ "$append" == "yes" ]]; then
# Pass affected files as arguments (NUL-delimited via stdin redirect — portable to BSD/macOS,
# unlike GNU-only `xargs -a`).
( cd "$ROOT" && xargs -0 $cmd < "$STATE/affected.0" ) >"$STATE/$name.log" 2>&1 || rc=$?
else
( cd "$ROOT" && $cmd ) >"$STATE/$name.log" 2>&1 || rc=$?
fi
if [[ $rc -eq 0 ]]; then gate_pass "$label"; else
gate_fail "$label (exit $rc) — see ${STATE#"$ROOT"/}/$name.log"
fi
}
run_gate "typecheck" typecheck ".typecheck_cmd" no
run_gate "lint" lint ".lint_cmd" yes
run_gate "format" format ".format_cmd" yes
run_gate "tests" tests ".test_cmd" yes
echo "" >&2
log_step "Reverting validation changes"
# (trap restore runs on exit)
echo "" >&2
echo "Gate results: $PASS passed, $FAIL failed, $SKIP skipped" >&2
if [[ $FAIL -ne 0 ]]; then
echo "${_C_RED}Do not proceed to step 05 until these pass.${_C_RESET}" >&2
exit 1
fi
log_ok "All enabled gates passed. Safe to apply at scale:"
echo " bash $SCRIPT_DIR/05-run-batched.sh $NAME" >&2
#!/usr/bin/env bash
# 05-run-batched.sh — apply the codemod at scale in resumable, verified, committed batches.
# Part of: codemod-react-pipeline (outer loop, step 5 — the mass apply)
#
# For each batch of files: apply → run per-batch gates → git commit. Progress is recorded so a
# crash (or Ctrl-C) resumes from the next unfinished batch. Refuses to run without a prior
# successful dry-run (step 03). The PreToolUse hook enforces the same rule for ad-hoc commands.
#
# Usage:
# bash 05-run-batched.sh <name> # process all remaining batches
# bash 05-run-batched.sh <name> --resume # same; explicit, prints prior progress
# bash 05-run-batched.sh <name> --batch-size N # override config.batch_size
# bash 05-run-batched.sh <name> --dry # show the batch plan, change nothing
#
# Exit: 0 = all batches done, 1 = a batch failed (stops; safe to fix and re-run), 2 = nothing to do.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib/common.sh"
NAME=""; BATCH_SIZE=""; PLAN_ONLY=0
while [[ $# -gt 0 ]]; do
case "$1" in
--resume) : ;; # default behaviour; flag is for readability
--batch-size) shift; BATCH_SIZE="${1:-}"; [[ "$BATCH_SIZE" =~ ^[0-9]+$ ]] || die "--batch-size needs a number" ;;
--dry) PLAN_ONLY=1 ;;
-*) die "Unknown flag: $1" ;;
*) [[ -z "$NAME" ]] && NAME="$1" || die "Unexpected argument: $1" ;;
esac
shift
done
[[ -n "$NAME" ]] || { echo "Usage: $0 <name> [--resume] [--batch-size N] [--dry]" >&2; exit 1; }
need_cmd git; need_cmd jq
require_codemod "$NAME"
require_dry_run "$NAME" # hard gate: no mass apply without an inspected dry-run
ROOT="$(target_root)"
STATE="$(state_dir_for "$NAME")"
[[ -z "$BATCH_SIZE" ]] && BATCH_SIZE="$(config_get '.batch_size' '500')"
PROGRESS="$STATE/progress.tsv" # lines: <batch-index>\t<status>\t<commit-sha>
FILELIST="$STATE/files.txt"
# --- Build (or reuse) the work list ----------------------------------------
if [[ ! -f "$FILELIST" ]]; then
log_step "Building file list from config.src_globs"
list_candidates | sort > "$FILELIST"
fi
TOTAL=$(wc -l < "$FILELIST" | tr -d ' ')
[[ "$TOTAL" -gt 0 ]] || die "No files to process. Check config.src_globs."
N_BATCHES=$(( (TOTAL + BATCH_SIZE - 1) / BATCH_SIZE ))
log_step "$NAME: $TOTAL files, $BATCH_SIZE per batch → $N_BATCHES batch(es)"
touch "$PROGRESS"
done_count=$(grep -c $'\tok\t' "$PROGRESS" 2>/dev/null || true); done_count=${done_count:-0}
[[ "$done_count" -gt 0 ]] && log_info "Resuming — $done_count batch(es) already complete."
if [[ $PLAN_ONLY -eq 1 ]]; then
log_ok "Plan only (no changes). $((N_BATCHES - done_count)) batch(es) remain."
exit 0
fi
require_clean_git # each batch commit must be isolated
# --- Per-batch gate runner (subset of step 04, scoped to the batch) --------
verify_batch() {
local files_nul="$1" rc=0
if gate_enabled typecheck; then
local tc; tc="$(config_get '.typecheck_cmd' '')"
[[ -n "$tc" ]] && { ( cd "$ROOT" && $tc ) >>"$STATE/batch.log" 2>&1 || rc=$?; }
fi
if [[ $rc -eq 0 ]] && gate_enabled lint; then
local lc; lc="$(config_get '.lint_cmd' '')"
[[ -n "$lc" ]] && { ( cd "$ROOT" && xargs -0 $lc < "$files_nul" ) >>"$STATE/batch.log" 2>&1 || rc=$?; }
fi
if [[ $rc -eq 0 ]] && gate_enabled tests; then
local testc; testc="$(config_get '.test_cmd' '')"
[[ -n "$testc" ]] && { ( cd "$ROOT" && xargs -0 $testc < "$files_nul" ) >>"$STATE/batch.log" 2>&1 || rc=$?; }
fi
return $rc
}
# --- Main batch loop -------------------------------------------------------
i=0
while [[ $i -lt $N_BATCHES ]]; do
idx=$i; i=$((i + 1))
# Skip already-completed batches (resumability).
if grep -q "^${idx}"$'\tok\t' "$PROGRESS"; then continue; fi
start=$(( idx * BATCH_SIZE + 1 ))
mapfile -t BATCH < <(sed -n "${start},$((start + BATCH_SIZE - 1))p" "$FILELIST")
[[ ${#BATCH[@]} -gt 0 ]] || continue
log_step "Batch $((idx + 1))/$N_BATCHES — ${#BATCH[@]} files"
: > "$STATE/batch.log"
# Apply only to this batch's files.
if ! ( cd "$ROOT" && codemod_apply "$NAME" "${BATCH[@]}" ) >>"$STATE/batch.log" 2>&1; then
git -C "$ROOT" checkout -- . 2>/dev/null || true
die "Batch $((idx + 1)) failed during apply. Log: ${STATE#"$ROOT"/}/batch.log. Fix and re-run to resume."
fi
# Nothing changed in this batch? Record and move on (idempotent / no matches here).
if git -C "$ROOT" diff --quiet; then
printf '%s\tok\t%s\n' "$idx" "(no-change)" >> "$PROGRESS"
log_info "No matches in this batch."
continue
fi
# Verify just this batch's affected files. Build the NUL list from an array (one NUL per file);
# `printf '%s\0' "$(...)"` would emit a single blob with embedded newlines.
mapfile -t BATCH_AFFECTED < <(git -C "$ROOT" diff --name-only --diff-filter=ACMR)
printf '%s\0' "${BATCH_AFFECTED[@]}" > "$STATE/batch-affected.0"
if ! verify_batch "$STATE/batch-affected.0"; then
git -C "$ROOT" checkout -- . 2>/dev/null || true
die "Batch $((idx + 1)) failed verification. Log: ${STATE#"$ROOT"/}/batch.log.
The batch was reverted. Fix the transform/gate, then re-run to resume from this batch."
fi
# Checkpoint commit (local — workflow.yaml 'commit:' steps are cloud-only; see gotchas.md).
git -C "$ROOT" add -A
msg="refactor($NAME): batch $((idx + 1))/$N_BATCHES [codemod-react-pipeline]"
git -C "$ROOT" commit -q -m "$msg"
sha="$(git -C "$ROOT" rev-parse --short HEAD)"
printf '%s\tok\t%s\n' "$idx" "$sha" >> "$PROGRESS"
log_ok "Committed $sha"
done
echo "" >&2
log_ok "All $N_BATCHES batch(es) applied and committed."
echo "Run the final check:" >&2
echo " bash $SCRIPT_DIR/verify.sh $NAME" >&2
echo "Roll back everything if needed (commits are tagged in the message):" >&2
echo " git log --oneline --grep='\\[codemod-react-pipeline\\]'" >&2
#!/usr/bin/env bash
# guardrail.sh — PreToolUse backend that blocks an unguarded mass codemod apply.
# Part of: codemod-react-pipeline (hook backend; see hooks/hooks.json)
#
# Fires on every Bash tool call while the skill session is active. It only ever blocks one thing:
# a `codemod jssg run` (or `ast-grep scan --update-all`) that targets a BROAD path WITHOUT
# --dry-run, when no dry-run sentinel exists for that codemod. Everything else is allowed.
#
# Contract (Claude Code hooks): read the tool input on stdin (JSON) or $TOOL_INPUT; print a
# reason to stderr and exit 2 to block; exit 0 to allow.
set -uo pipefail
# --- Obtain the command being run ------------------------------------------
INPUT="${TOOL_INPUT:-}"
if [[ -z "$INPUT" && ! -t 0 ]]; then INPUT="$(cat || true)"; fi
# Pull the bash command string out of the JSON if jq is available; else use raw input.
CMD="$INPUT"
if command -v jq >/dev/null 2>&1; then
parsed="$(printf '%s' "$INPUT" | jq -r '.command // .tool_input.command // empty' 2>/dev/null || true)"
[[ -n "$parsed" ]] && CMD="$parsed"
fi
allow() { exit 0; }
block() {
echo "[codemod-react-pipeline] BLOCKED: $1" >&2
echo "" >&2
echo "Mass codemod applies must be preceded by an inspected dry-run." >&2
echo " 1. bash scripts/03-dry-run.sh <name> # preview + write sentinel" >&2
echo " 2. bash scripts/04-validate-findings.sh <name> # gates" >&2
echo " 3. bash scripts/05-run-batched.sh <name> # safe, batched, resumable apply" >&2
echo "" >&2
echo "Override (you accept the risk): add --dry-run, narrow the target, or 'touch' the sentinel." >&2
exit 2
}
# --- Only consider real apply commands -------------------------------------
is_apply=0
case "$CMD" in
*"codemod jssg run"*|*"jssg run"*) is_apply=1 ;;
*"ast-grep scan"*"--update-all"*|*"ast-grep scan"*"-U"*) is_apply=1 ;;
esac
[[ $is_apply -eq 1 ]] || allow
# Our own pipeline scripts handle gating internally — never block them.
case "$CMD" in
*"05-run-batched.sh"*|*"03-dry-run.sh"*|*"04-validate-findings.sh"*|*"02-inner-loop.sh"*) allow ;;
esac
# A dry-run is self-evidently safe.
case "$CMD" in *"--dry-run"*) allow ;; esac
# If ANY dry-run sentinel exists under a state dir, the homework has been done — allow.
# (Per-codemod precision is enforced by 05-run-batched.sh's own require_dry_run; this hook is
# defense-in-depth, so a coarse "a dry-run happened somewhere" signal is sufficient.)
repo_root="$(git rev-parse --show-toplevel 2>/dev/null || echo .)"
state_base="$(jq -r '.state_dir // ".codemod-pipeline"' "${CLAUDE_PLUGIN_ROOT:-.}/config.json" 2>/dev/null || echo .codemod-pipeline)"
[[ -z "$state_base" || "$state_base" == "null" ]] && state_base=".codemod-pipeline"
if compgen -G "$repo_root/$state_base/*/dry-run.ok" >/dev/null 2>&1; then
allow
fi
# Otherwise block. Inner-loop single-file trials go through 02-inner-loop.sh (allow-listed above);
# a manual single-file run should add --dry-run or use that script. We err toward blocking because
# the cost of a wrongful block is one flag, and the cost of a wrongful allow is a mass apply.
block "'jssg run' / 'ast-grep --update-all' without --dry-run and no dry-run sentinel on record"
#!/usr/bin/env bash
# common.sh — shared helpers for the codemod-react-pipeline scripts.
# Part of: codemod-react-pipeline
#
# Source this from every step script:
# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# source "$SCRIPT_DIR/lib/common.sh"
#
# Conventions used across the pipeline:
# - The TARGET repo is the current working directory ($PWD), not the skill dir.
# - Per-codemod state lives under <state_dir>/<codemod-name>/ in the target repo.
# - Exit codes: 0 = success, 1 = error, 2 = skipped / already done / precondition unmet.
set -euo pipefail
# --- Locations -------------------------------------------------------------
# Directory of the script that sourced us (scripts/), and the skill root.
PIPELINE_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PIPELINE_SKILL_DIR="$(cd "$PIPELINE_SCRIPT_DIR/.." && pwd)"
PIPELINE_CONFIG_FILE="${CODEMOD_PIPELINE_CONFIG:-$PIPELINE_SKILL_DIR/config.json}"
PIPELINE_TEMPLATES_DIR="$PIPELINE_SKILL_DIR/assets/templates"
# --- Logging ---------------------------------------------------------------
_pp_is_tty() { [[ -t 2 ]]; }
if _pp_is_tty; then
_C_RESET=$'\033[0m'; _C_DIM=$'\033[2m'; _C_RED=$'\033[31m'
_C_YEL=$'\033[33m'; _C_GRN=$'\033[32m'; _C_BLU=$'\033[34m'
else
_C_RESET=""; _C_DIM=""; _C_RED=""; _C_YEL=""; _C_GRN=""; _C_BLU=""
fi
log_step() { echo "${_C_BLU}==>${_C_RESET} $*" >&2; }
log_info() { echo "${_C_DIM} $*${_C_RESET}" >&2; }
log_ok() { echo "${_C_GRN} ✓ ${_C_RESET}$*" >&2; }
log_warn() { echo "${_C_YEL} ! ${_C_RESET}$*" >&2; }
# die <message...> — print an actionable error and exit 1.
die() { echo "${_C_RED}Error:${_C_RESET} $*" >&2; exit 1; }
# skip <message...> — print a reason and exit 2 (precondition not met / nothing to do).
skip() { echo "${_C_YEL}Skip:${_C_RESET} $*" >&2; exit 2; }
# --- Dependency checks -----------------------------------------------------
# need_cmd <command> [install-hint] — fail with a hint if a tool is missing.
need_cmd() {
local cmd="$1" hint="${2:-}"
command -v "$cmd" >/dev/null 2>&1 && return 0
if [[ -n "$hint" ]]; then
die "'$cmd' is required but not found. $hint"
fi
die "'$cmd' is required but not found in PATH."
}
# codemod_cli — echo how to invoke the codemod CLI (global binary or npx fallback).
codemod_cli() {
if command -v codemod >/dev/null 2>&1; then
echo "codemod"
else
echo "npx --yes codemod"
fi
}
# --- Config access ---------------------------------------------------------
# config_get <jq-path> [default] — read a scalar from config.json.
config_get() {
local path="$1" default="${2:-}"
[[ -f "$PIPELINE_CONFIG_FILE" ]] || { echo "$default"; return 0; }
local val
val="$(jq -r "${path} // empty" "$PIPELINE_CONFIG_FILE" 2>/dev/null || true)"
if [[ -z "$val" || "$val" == "null" ]]; then echo "$default"; else echo "$val"; fi
}
# config_get_array <jq-path> — print array elements, one per line.
config_get_array() {
local path="$1"
[[ -f "$PIPELINE_CONFIG_FILE" ]] || return 0
jq -r "${path}[]? // empty" "$PIPELINE_CONFIG_FILE" 2>/dev/null || true
}
# gate_enabled <name> — true if config.gates.<name> is not explicitly false.
gate_enabled() {
local name="$1" v
v="$(config_get ".gates.${name}" "true")"
[[ "$v" != "false" ]]
}
# --- Target repo / git -----------------------------------------------------
# target_root — absolute path to the git repo we are transforming.
target_root() {
git rev-parse --show-toplevel 2>/dev/null \
|| die "Not inside a git repository. Run the pipeline from the codebase you want to transform."
}
# require_clean_git — refuse to proceed with uncommitted changes (so rollback is trivial).
require_clean_git() {
git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|| die "Not inside a git repository. The pipeline needs git for safe rollback."
if [[ -n "$(git status --porcelain)" ]]; then
die "Working tree is dirty. Commit or stash first so each batch is reversible:
git stash # park your changes, or
git add -A && git commit -m 'wip'"
fi
}
# --- State ------------------------------------------------------------------
# state_dir_for <codemod-name> — ensure and echo the per-codemod state directory.
state_dir_for() {
local name="$1"
[[ -n "$name" ]] || die "state_dir_for: codemod name is required."
local base; base="$(config_get '.state_dir' '.codemod-pipeline')"
local dir="$(target_root)/$base/$name"
mkdir -p "$dir"
echo "$dir"
}
# dry_run_sentinel <codemod-name> — path to the file proving a dry-run happened.
dry_run_sentinel() { echo "$(state_dir_for "$1")/dry-run.ok"; }
# require_dry_run <codemod-name> — block mass apply unless 03-dry-run.sh has run.
require_dry_run() {
local name="$1" sentinel; sentinel="$(dry_run_sentinel "$name")"
[[ -f "$sentinel" ]] || die "No dry-run on record for '$name'.
Run: bash $PIPELINE_SCRIPT_DIR/03-dry-run.sh $name
then: bash $PIPELINE_SCRIPT_DIR/04-validate-findings.sh $name
before applying changes at scale."
}
# --- Codemod project layout -------------------------------------------------
# codemod_proj_dir <codemod-name> — where a scaffolded codemod lives in the target repo.
codemod_proj_dir() { echo "$(target_root)/codemods/$1"; }
# transform_file <codemod-name> — path to the JSSG transform (created by 01-scaffold.sh).
transform_file() {
local d; d="$(codemod_proj_dir "$1")"
if [[ -f "$d/transform.ts" ]]; then echo "$d/transform.ts"
elif [[ -f "$d/rule.yml" ]]; then echo "$d/rule.yml"
else echo "$d/transform.ts"; fi
}
# require_codemod <codemod-name> — fail if the codemod has not been scaffolded.
require_codemod() {
local name="$1" d; d="$(codemod_proj_dir "$name")"
[[ -d "$d" ]] || die "Codemod '$name' not found at $d.
Scaffold it first: bash $PIPELINE_SCRIPT_DIR/01-scaffold.sh $name"
}
# --- Affected files ---------------------------------------------------------
# affected_files — list of currently-modified tracked files (post-run), one per line.
affected_files() {
git -C "$(target_root)" diff --name-only --diff-filter=ACMR
}
# codemod_apply <name> <path...> — apply the scaffolded codemod (transform.ts or rule.yml)
# to the given paths IN PLACE (no dry-run). Returns the CLI's exit status.
codemod_apply() {
local name="$1"; shift
local proj language cm
proj="$(codemod_proj_dir "$name")"
language="$(config_get '.language' 'tsx')"
cm="$(codemod_cli)"
local -a threads=()
local mt; mt="$(config_get '.max_threads' '0')"
[[ "$mt" =~ ^[0-9]+$ && "$mt" -gt 0 ]] && threads=(--max-threads "$mt")
if [[ -f "$proj/transform.ts" ]]; then
# shellcheck disable=SC2086
$cm jssg run "$proj/transform.ts" "$@" --language "$language" "${threads[@]}"
elif [[ -f "$proj/rule.yml" ]]; then
command -v ast-grep >/dev/null 2>&1 || die "ast-grep not installed (needed to apply rule.yml). Install it or convert to a JSSG transform."
ast-grep scan --rule "$proj/rule.yml" --update-all "$@"
else
die "No transform.ts or rule.yml for '$name'."
fi
}
# _pathspecs — print config.src_globs as git :(glob) pathspecs, one per line.
# :(glob) magic makes ** match across directories; * stops at /. Braces are NOT supported,
# so config.src_globs must list one glob per extension.
_pathspecs() {
local g
while IFS= read -r g; do
[[ -n "$g" ]] && printf ':(glob)%s\n' "$g"
done < <(config_get_array '.src_globs')
}
# list_candidates — tracked files matching config.src_globs, one per line (NUL-safe internally).
list_candidates() {
local root; root="$(target_root)"
local -a specs=(); local s
while IFS= read -r s; do specs+=("$s"); done < <(_pathspecs)
[[ ${#specs[@]} -gt 0 ]] || specs=(':(glob)src/**/*.ts' ':(glob)src/**/*.tsx' ':(glob)src/**/*.js' ':(glob)src/**/*.jsx')
git -C "$root" ls-files -- "${specs[@]}" 2>/dev/null
}
# count_candidates — number of files matching config.src_globs (the blast-radius ceiling).
count_candidates() { list_candidates | wc -l | tr -d ' '; }
#!/usr/bin/env bash
# selftest.sh — verify the codemod-react-pipeline's own scripts and assets are well-formed.
# Part of: codemod-react-pipeline
#
# This is the skill's test harness (TDD gate). It checks structure, not transform behaviour:
# - every script parses (bash -n) and declares strict mode + a shebang
# - hooks/hooks.json is valid JSON with a PreToolUse entry
# - assets/templates exist
# - if the `codemod` CLI is installed, the workflow template validates
# - if validate-skill.js is reachable, the skill validates structurally
#
# Usage: bash scripts/selftest.sh
# Exit: 0 = all checks pass, 1 = a check failed.
set -uo pipefail # not -e: we want to run every check and tally failures
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
PASS=0
FAIL=0
ok() { echo " ✓ $*"; PASS=$((PASS + 1)); }
bad() { echo " ✗ $*"; FAIL=$((FAIL + 1)); }
note() { echo " - $*"; }
echo "Self-testing codemod-react-pipeline ($SKILL_DIR)"
echo ""
# --- 1. Scripts: syntax, shebang, strict mode ------------------------------
echo "Scripts:"
shopt -s nullglob
mapfile -t SCRIPTS < <(find "$SCRIPT_DIR" -name '*.sh' -type f | sort)
[[ ${#SCRIPTS[@]} -gt 0 ]] || bad "no scripts found under $SCRIPT_DIR"
for s in "${SCRIPTS[@]}"; do
rel="${s#"$SKILL_DIR"/}"
if bash -n "$s" 2>/dev/null; then ok "$rel parses"; else bad "$rel has a syntax error"; fi
head -n1 "$s" | grep -q '^#!' || bad "$rel missing shebang"
# Strict mode: require nounset (-u) AND pipefail. errexit (-e) is intentionally optional —
# the all-gates/all-checks scripts omit it on purpose (they tally and always reach cleanup).
{ grep -Eq 'set -[a-z]*u' "$s" && grep -q 'pipefail' "$s"; } \
|| bad "$rel missing strict mode (need set -u and pipefail)"
done
echo ""
# --- 2. Hooks --------------------------------------------------------------
echo "Hooks:"
HOOKS="$SKILL_DIR/hooks/hooks.json"
if [[ -f "$HOOKS" ]]; then
if command -v jq >/dev/null 2>&1; then
if jq -e . "$HOOKS" >/dev/null 2>&1; then ok "hooks/hooks.json is valid JSON"; else bad "hooks/hooks.json is not valid JSON"; fi
jq -e '.hooks.PreToolUse' "$HOOKS" >/dev/null 2>&1 \
&& ok "hooks/hooks.json declares a PreToolUse guard" \
|| bad "hooks/hooks.json has no PreToolUse entry"
else
note "jq not installed — skipping hooks JSON check"
fi
else
bad "hooks/hooks.json is missing"
fi
echo ""
# --- 3. Templates ----------------------------------------------------------
echo "Templates:"
for t in transform.ts.template astgrep-rule.yml.template workflow.yaml.template codemod.yaml.template; do
[[ -f "$SKILL_DIR/assets/templates/$t" ]] && ok "assets/templates/$t present" || bad "assets/templates/$t missing"
done
[[ -f "$SKILL_DIR/assets/templates/fixtures/input.tsx.template" ]] \
&& ok "fixture templates present" || bad "fixture templates missing"
echo ""
# --- 4. Workflow template validates (optional) -----------------------------
echo "Workflow template:"
WF="$SKILL_DIR/assets/templates/workflow.yaml.template"
if command -v codemod >/dev/null 2>&1 && [[ -f "$WF" ]]; then
# Render to a temp file (substitute __PLACEHOLDER__ tokens; leave codemod ${{ }} intact) and validate.
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
sed -E 's/__[A-Z0-9_]+__/placeholder/g' "$WF" > "$tmp/workflow.yaml"
if codemod workflow validate -w "$tmp/workflow.yaml" >/dev/null 2>&1; then
ok "workflow.yaml.template validates with codemod CLI"
else
note "codemod workflow validate reported issues (placeholders may not satisfy schema) — review manually"
fi
else
note "codemod CLI not installed — skipping workflow validation"
fi
echo ""
# --- 5. Structural skill validation (optional) -----------------------------
echo "Skill structure:"
VALIDATOR="${VALIDATE_SKILL_JS:-}"
if [[ -z "$VALIDATOR" ]]; then
VALIDATOR="$(find "$HOME/.claude/plugins/cache" -name validate-skill.js -path '*dev-skill*' 2>/dev/null | head -n1 || true)"
fi
if [[ -n "$VALIDATOR" && -f "$VALIDATOR" ]] && command -v node >/dev/null 2>&1; then
if node "$VALIDATOR" "$SKILL_DIR" >/dev/null 2>&1; then
ok "validate-skill.js passed"
else
bad "validate-skill.js reported errors (run it directly to see them)"
fi
else
note "validate-skill.js not found (set VALIDATE_SKILL_JS=/path) — skipping"
fi
echo ""
# --- Summary ---------------------------------------------------------------
echo "Results: $PASS passed, $FAIL failed"
[[ $FAIL -eq 0 ]] || exit 1
#!/usr/bin/env bash
# verify.sh — final assertions over a completed migration.
# Part of: codemod-react-pipeline (outer loop, step 6 — sign-off)
#
# Run after 05-run-batched.sh. Asserts the end state is correct (not just "commands exited 0"):
# - every planned batch is recorded complete
# - the codemod is now a no-op (re-running it changes nothing → migration is complete + idempotent)
# - the project type-checks
# - no leftover migration markers (CODEMOD-TODO) remain
#
# Usage: bash verify.sh <name>
# Exit: 0 = all assertions pass, 1 = a failure.
set -uo pipefail # run all assertions, tally
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib/common.sh"
NAME="${1:-}"
[[ -n "$NAME" ]] || { echo "Usage: $0 <name>" >&2; exit 1; }
need_cmd git; need_cmd jq
require_codemod "$NAME"
ROOT="$(target_root)"
STATE="$(state_dir_for "$NAME")"
PASS=0; FAIL=0
assert_pass() { echo "${_C_GRN} PASS${_C_RESET} $*" >&2; PASS=$((PASS+1)); }
assert_fail() { echo "${_C_RED} FAIL${_C_RESET} $*" >&2; FAIL=$((FAIL+1)); }
log_step "Verifying migration '$NAME'"
# --- 1. All batches recorded complete --------------------------------------
PROGRESS="$STATE/progress.tsv"
if [[ -f "$PROGRESS" ]]; then
if grep -q $'\tfail\t' "$PROGRESS"; then
assert_fail "progress log contains failed batches — re-run 05-run-batched.sh to finish"
else
done_n=$(grep -c $'\tok\t' "$PROGRESS"); assert_pass "$done_n batch(es) recorded complete, none failed"
fi
else
assert_fail "no progress log at ${PROGRESS#"$ROOT"/} — has 05-run-batched.sh run?"
fi
# --- 2. Codemod is now a no-op (complete + idempotent) ---------------------
if [[ -n "$(git -C "$ROOT" status --porcelain)" ]]; then
assert_fail "working tree is dirty — commit or inspect before verifying re-application"
else
codemod_apply "$NAME" "$ROOT" >/dev/null 2>&1 || true
if git -C "$ROOT" diff --quiet; then
assert_pass "re-running the codemod produces no changes (migration complete + idempotent)"
else
leftover=$(git -C "$ROOT" diff --name-only | wc -l | tr -d ' ')
assert_fail "re-running the codemod still changes $leftover file(s) — migration is incomplete"
fi
git -C "$ROOT" checkout -- . 2>/dev/null || true
fi
# --- 3. Project type-checks ------------------------------------------------
if gate_enabled typecheck; then
TC="$(config_get '.typecheck_cmd' '')"
if [[ -n "$TC" ]]; then
if ( cd "$ROOT" && $TC ) >"$STATE/verify-typecheck.log" 2>&1; then
assert_pass "typecheck passes"
else
assert_fail "typecheck failed — see ${STATE#"$ROOT"/}/verify-typecheck.log"
fi
fi
fi
# --- 4. No leftover migration markers --------------------------------------
mapfile -t MARKER_SPECS < <(_pathspecs)
[[ ${#MARKER_SPECS[@]} -gt 0 ]] || MARKER_SPECS=(':(glob)src/**/*.tsx')
markers=$(git -C "$ROOT" grep -lI 'CODEMOD-TODO' -- "${MARKER_SPECS[@]}" 2>/dev/null | wc -l | tr -d ' ')
if [[ "$markers" -eq 0 ]]; then
assert_pass "no CODEMOD-TODO markers left behind"
else
assert_fail "$markers file(s) still contain CODEMOD-TODO — resolve before sign-off"
fi
echo "" >&2
echo "Verify results: $PASS passed, $FAIL failed" >&2
[[ $FAIL -eq 0 ]] || exit 1
log_ok "Migration '$NAME' verified."
Related skills
FAQ
What does codemod-react-pipeline do?
codemod-react-pipeline is a Claude Code skill for frontend development. It helps developers move faster with AI-assisted coding.
When should I use codemod-react-pipeline?
When you need to helps with frontend development tasks during ai-assisted development, or when codemod-react-pipeline is a claude code skill for frontend development. it helps developers move faster with ai-assisted coding.
What are the main capabilities?
codemod-react-pipeline; Frontend Development; AI-coding skill.