Legacy code modernization at scale with Codex
PLCodex modernizes a legacy codebase in waves, not one rewrite. A read-only assessment ranks modules by risk, characterization tests pin current behavior, low-risk layers migrate in parallel Git worktrees, and the hardest modules go to Codex cloud tasks with two to four attempts. Each wave ships with the same evidence: unchanged tests passing, type gates, and a file-count ratchet.
Your team owns 80,000 lines of JavaScript from 2018: callbacks instead of async/await, React class components, and tests that cover a fraction of the code. Management wants TypeScript “without production regressions”, and the last big-bang rewrite ran for six months without shipping. This page is for the developer who runs that migration with Codex and the tech lead who has to prove each wave is safe without reading every migrated file.
The method itself (slicing, waves, cut-over evidence) is tool-neutral and lives in legacy modernization with agents. This page is its Codex implementation: which Codex surface does which job, the exact commands, and the prompts.
What you’ll walk away with from a Codex modernization
Section titled “What you’ll walk away with from a Codex modernization”- A job-to-surface map: which parts of a migration run in the CLI, in worktrees, in the cloud or on a schedule.
- Seven copy-paste prompts: assessment, characterization tests, migration infrastructure, a batch migration, a hard-module best-of-N task, class-to-hooks, and a weekly next-wave report.
- A progress script and a CI ratchet that stop the JavaScript file count from going back up.
- An evidence checklist a tech lead can sign off per wave, and recovery steps for the failures that recur.
Which Codex surface does each modernization job?
Section titled “Which Codex surface does each modernization job?”Match the surface to the risk and the amount of judgment involved. Commands below were checked against codex-cli 0.157.1.
| Job | Surface | Why there |
|---|---|---|
| Assess the codebase | codex exec with a read-only permission profile | Nothing should change yet, and the report is saved to a file |
| Characterization tests and migration infrastructure | Interactive codex on a dedicated branch | Shared config (tsconfig.json, lint, CI) needs one owner and your attention |
| Mechanical batches (utilities, data access, routes) | One Git worktree per batch, one Codex session each | Batches run in parallel without touching each other’s files |
| Hard modules (deep callbacks, hidden globals) | codex cloud exec --attempts 2–4 | Several valid designs exist, so you compare diffs instead of trusting one |
| Progress tracking and next-batch choice | A shell script plus a weekly Codex automation | Counting is deterministic; only the recommendation needs a model |
Codex’s bundled default model, GPT-6.1 Sol, runs at low reasoning effort by default. Raise the effort for the assessment and for hard modules you migrate locally before you consider another model: add -c model_reasoning_effort="high" to a CLI run, as Step 1 does, or pick the level with /model in an interactive session. The models hub has the current lineup.
Step 1: Assess the codebase read-only
Section titled “Step 1: Assess the codebase read-only”Run the assessment non-interactively so it cannot edit anything and its report lands in a file you can commit. Save the prompt below as assess.md, then run this in your terminal at the repository root:
mkdir -p docs/modernization &&codex exec -c default_permissions=":read-only" -c model_reasoning_effort="high" \ -o docs/modernization/assessment.md - < assess.md-c default_permissions=":read-only" selects the built-in read-only permission profile (beta in 0.157.1), -c model_reasoning_effort="high" raises the effort for this run only, and -o writes the agent’s final message to the file. The mkdir -p makes sure the output directory exists on a fresh repository.
Read the risk ranking, not the whole report. If a module you know is fragile landed in “low”, that is a sign the assessment missed hidden state; add it to the high-risk list by hand.
Step 2: Pin current behavior before any file changes
Section titled “Step 2: Pin current behavior before any file changes”A migration with thin test coverage has no oracle: the agent can change behavior and every check stays green. Before a directory enters a wave, give its public interface characterization tests that record what the code does today, bugs included. The cross-tool technique is in characterization tests for legacy code.
Check that the tests can fail before you trust them. Break one line of a module by hand, rerun the characterization suite, and confirm at least one test goes red; then revert. A suite that stays green against a deliberate bug is not an oracle. Commit the tests on the base branch so every later wave runs against the same files.
Step 3: Build the migration infrastructure on one branch
Section titled “Step 3: Build the migration infrastructure on one branch”Shared configuration is the file every parallel batch would otherwise touch, so one session on one branch owns it. Start an interactive session on a branch such as ts-migration-infra, give it the prompt below, and merge the result before any batch starts.
The progress script counts, and the ratchet keeps the count from rising. Both are deterministic, so neither needs a model:
#!/usr/bin/env bash# scripts/migration-progress.sh: JS vs TS files per top-level directory under src/set -euo pipefailprintf '%-28s %6s %6s\n' directory js tsfor d in src/*/; do js=$(find "$d" -type f \( -name '*.js' -o -name '*.jsx' \) | wc -l) ts=$(find "$d" -type f \( -name '*.ts' -o -name '*.tsx' \) | wc -l) printf '%-28s %6d %6d\n' "${d%/}" "$js" "$ts"done# CI step: the ratchet. Lower .migration-baseline on main after a batch merges, never inside a batch PR.js_now=$(find src -type f \( -name '*.js' -o -name '*.jsx' \) | wc -l)test "$js_now" -le "$(cat .migration-baseline)" || { echo "JS file count rose to $js_now"; exit 1; }Step 4: Migrate low-risk layers in parallel worktrees
Section titled “Step 4: Migrate low-risk layers in parallel worktrees”Take the first wave from the assessment, usually leaf utilities, then data access, then services, and routes or UI last. Give each batch its own worktree and branch so two sessions never edit the same file. Run this in your terminal once per batch:
git fetch origingit worktree add -b ts/utils ../app-ts-utils origin/maincd ../app-ts-utils && npm ci && codexcodex --worktree creates a managed worktree instead when you do not need to choose the branch name; the Codex worktrees guide covers both forms and the cleanup. Paste the same batch prompt into every session and change only the directory:
Merge one batch at a time. After each merge, lower .migration-baseline on main to the new JS count in a one-line follow-up commit. Keeping the baseline out of batch pull requests is what keeps parallel batches from colliding: if every batch lowered it, every merge after the first would conflict on that one file. Then rebase the other open batches onto the new base and rerun their gates, because a type added in one batch can surface errors in another.
Step 5: Send hard modules to cloud tasks with best-of-N
Section titled “Step 5: Send hard modules to cloud tasks with best-of-N”Some modules have several reasonable designs: six levels of nested callbacks, a class hierarchy that leans on a global config object, no tests. For those, codex cloud exec --attempts N runs up to four independent attempts on the same prompt, and you compare the diffs. Each attempt is a full agent run, so reserve it for modules where a wrong first design is expensive.
The cloud container clones from your remote, so the infrastructure from Step 3 and the characterization tests from Step 2 must already be on the remote main. Push it, then target the branch explicitly (run in your terminal, where ENV_ID is your Codex cloud environment ID or label):
git push origin maincodex cloud exec --env ENV_ID --branch main --attempts 3 - < hard-module.mdNaming the designs keeps the attempts from converging on the same diff. When the task is ready, compare the attempts and apply the one you choose in a clean worktree on a new branch, not in your working copy, so the applied diff is ready for a pull request:
codex cloud diff TASK_ID --attempt 2git fetch origingit worktree add -b ts/payments ../verify-payments origin/main && cd ../verify-paymentscodex cloud apply TASK_ID --attempt 2npm ci && npm run typecheck && npm testTASK_ID is the ID or task URL that codex cloud exec prints. For a prompt that compares all attempts side by side, see when best-of-N is worth paying for.
Migrate React class components to hooks
Section titled “Migrate React class components to hooks”Class-to-hooks is the migration where behavior shifts most quietly: effects run after paint, and a stale closure reads an old prop. Treat it as a medium-risk wave with its own prompt:
Step 6: Track progress and pick the next wave on a schedule
Section titled “Step 6: Track progress and pick the next wave on a schedule”Run scripts/migration-progress.sh in CI on every merge so the numbers come from a script, not from an agent’s estimate. Then let a weekly Codex automation turn those numbers into a recommendation. Automations run in the Codex desktop app, which exists for macOS and Windows; on Linux, schedule codex exec with the same prompt from cron or CI. Run the prompt by hand twice before you schedule it, as the automations guide describes. Because the job appends to docs/modernization/progress.md, run it in a worktree and merge that change like any other branch; a worktree run starts from a fresh checkout, so an unmerged table is gone next week.
How do you verify a modernization wave without reading every line?
Section titled “How do you verify a modernization wave without reading every line?”A migrated file is correct when it still does what it did. Decide what each wave must prove before any diff exists, and let the gates check it:
-
The oracle did not move. Characterization and existing tests pass, and the wave did not edit them.
git diff --stat origin/main...HEAD -- tests/must be empty (or list only the allowed class-API replacements). -
The type and lint gates pass in CI, not only in the agent’s summary:
npm run typecheck, the linter and the full test suite. -
The ratchet moved in the right direction. The JS file count in the agent’s report fell, the pull request did not touch
.migration-baseline, and the baseline is lowered onmainright after the merge. -
An automated reviewer read the diff first. In the batch’s worktree, run
git fetch originfirst so the review and thetests/check in item 1 compare against the same base, then run the built-in review withcodex exec review --base origin/main, or give custom instructions without--base:codex exec review "Review the changes on this branch against origin/main. Flag any behavior change: altered error handling, callback-to-promise timing, changed export shapes, or edits to tests."In codex-cli 0.157.1,--baseand custom instructions cannot be combined; the command exits with an error. You can also ask for@codex reviewon the pull request. See Codex code review. -
A named person signs off. The batch owner merges low-risk waves on the evidence above. A module owner reads the code itself only for the classes where humans still read diffs (authentication, money, schema and data migrations), as reading evidence instead of code sets out.
-
Production can undo it. Ship each wave as its own deploy so a regression rolls back one wave, not the migration.
What breaks when Codex modernizes a legacy codebase?
Section titled “What breaks when Codex modernizes a legacy codebase?”| Symptom | Cause | Recovery |
|---|---|---|
An unmigrated .js file fails with “is not a function” after a batch merges | The batch turned module.exports = fn into named exports, and require() callers still expect the old shape | Revert the batch’s export change, then keep the old export shape until every importer is migrated. The batch prompt’s importer list shows who is affected |
| Tests pass, but a callback now fires later than before | async/await moved work from a synchronous callback to a microtask | Add a characterization test that asserts the ordering, then rerun the batch against it |
| A batch is green only because tests changed | The agent edited assertions or snapshots to match new behavior | The empty tests/ diff check blocks the merge; rerun with “fix the migration, not the test” at the top of the prompt |
The cloud task cannot find tsconfig.json or the new tests | The merged infrastructure and tests were not pushed to the remote main, or the task ran on another branch | Push first and pass --branch explicitly |
| All three attempts return the same design | The prompt left only one obvious path | Name the alternative designs in the prompt, as the hard-module prompt does |
| Type errors spread to files nobody touched | A shared type or tsconfig.json changed inside a batch | Keep shared config in the infrastructure branch only; batches may not edit it |
Batch pull requests conflict on .migration-baseline | Each batch lowered the baseline itself | Drop the baseline change from the batch; lower it on main after each merge, taking the lower count if two merges race |
| Two batches conflict on merge | Their directories share a module, or both edited an index file | Rebase the later batch after the first merges; assign shared index files to one batch in the wave plan |
| The weekly report re-lists last week’s recommendation | The automation has no memory of what already happened | Keep docs/modernization/progress.md as its state and have the prompt compare against it |
Where to go next with legacy modernization
Section titled “Where to go next with legacy modernization”The same migration in the other tools: refactoring legacy code with Cursor and migrations with Claude Code.