Your team already has the mechanical-migration playbook — the worked recipe for surveying the blast radius, converting the straightforward cases to match a hand-done example, flagging the hard ones, and running the suite to prove nothing broke. This module is the layer above the recipe. It’s where you master the judgment that makes a migration trustworthy — the blast-radius thinking that decides the scope before a single file is touched, the mechanical/judgment split that keeps the hard cases from being silently mangled, and the documented decision that turns a flagged TODO into something the next engineer can actually act on.
“The ‘couldn’t auto-convert’ list is the most important output — read it carefully. That’s exactly where the real decisions and the subtle bugs hide.” This is the line the playbook drives at. Mastery is the judgment that turns that list from a collection of TODO comments into a set of documented decisions: what the problem was, what the options were, and what you chose and why.
The blast-radius survey — before a single file is touched
The playbook tells you to survey every usage before converting anything. Mastery is the discipline that makes that survey do real work rather than ceremonial work.
- Group by conversion complexity, not by file. A survey that returns “82 files have usages” is not useful. A survey that groups them — “62 straightforward
.format()calls that map one-to-one, 9 that use.utcOffset()which has no direct equivalent, 3 that chain multiple operations in a pattern date-fns doesn’t support” — is what you can actually make a plan from. The grouping is the insight; the file count is the noise. - Find the weird usages first. The most valuable part of a blast-radius survey is the outliers — the usage that does something unusual, the function call chained in an unexpected way, the import that’s dynamic rather than static. Those are the cases that will fail silently rather than loudly if the migration gets them wrong, and they’re the cases the mechanical pass will most likely mangle if it doesn’t know they’re there.
- “Don’t change anything yet” is the load-bearing instruction. The survey and the conversion are two separate moves. The survey tells you what you’re in for; the conversion is what you do about it. Running them together — surveying as you convert — means making case-by-case decisions without the full picture of what you’re converting. Separate them, always.
- Use a subagent for large repos. On a repo with hundreds of modules, the survey can crowd out the context you need for the conversion. The mechanical-migration playbook mentions reaching for a subagent for the wide search — this is not optional on a large repo. A survey that samples the repo and calls itself complete is a survey that missed the three unusual usages that will break at runtime.
The mechanical majority — convert to match the example, leave the rest flagged
The playbook asks you to convert the straightforward cases to match a hand-done example and leave the hard cases with a TODO comment. Mastery is the judgment that distinguishes a straightforward case from a hard one — and the vigilance that catches a hard case that was silently converted as if it were straightforward.
- The hand-done example is the contract. Before Claude converts a single file, you show it one example you’ve already converted correctly. That example is the target — Claude matches its output to that example for every case it judges straightforward. Without the example, Claude is guessing at the intended transformation; with it, the mechanical pass is verifiable against something concrete you’ve already approved.
- “No clean equivalent” means leave it alone. When a usage has no one-to-one equivalent in the target — a
moment.jsfunction that requires composition of threedate-fnsfunctions, a locale-handling feature that works differently in the target, a timezone offset pattern that needs a judgment call — the correct response is to leave it exactly as-is and mark it. Not to approximate it, not to try the closest thing and see if the tests catch it, not to silently choose the option that compiles. Flagged and unchanged is the honest output; “migrated” with a subtle behaviour difference is the dangerous one. - The diff covers the mechanical cases separately from the decisions. After the conversion pass, the diff should be stageable in two clear groups: the mechanical conversions (the boring 90%, one concern) and the hand-decided cases (the 10% that needed judgment, a separate concern). This separation makes the PR reviewable — a reviewer can read the mechanical sweep quickly and focus their attention on the decided cases, where the real changes happened.
- Never claim “migrated everything” if there are flagged cases. A migration that converts every file and marks nothing as needing a decision is a migration that either had no hard cases (unusual) or mangled the hard cases (common). The mechanical pass is not done when the flags are empty; it’s done when the flags accurately represent every case that needed judgment.
The documented decision — what turns a TODO into a real output
The playbook mentions flagging hard cases with // TODO: migrate manually — comments. Mastery is the judgment that turns those flags into documented decisions: not “this needs attention” but “here’s what the problem was, here are the options, here’s what I decided and why.”
- A TODO that’s an explanation is better than a TODO that’s a request. ”// TODO: migrate manually — this uses
.utcOffset()which has no direct equivalent in date-fns” is a flag. ”// DECISION: kept moment.js for this usage..utcOffset()maps to date-fnsdifferenceInMinutes(toDate(d), new Date())but the calculation differs by DST handling — verified this behaviour is intentional (the billing timestamps are always in Gulf Standard Time with no DST offset). Usedformat(d, "yyyy-MM-dd'T'HH:mm:ss+04:00")instead.” is a decision — a future engineer can understand what the problem was, what was considered, and what was chosen. - Each decision has options and a tradeoff. The playbook says: “for each flagged case, show the old code, explain why there’s no clean equivalent, and give me the 2 best options with the tradeoff. I’ll decide each one.” Mastery is actually going through that loop for each flagged case — not leaving the cases flagged for a future sprint. The module is assessed on whether the hard cases have documented decisions, not just flags.
- Your judgment is the input that closes the loop. Claude lays out the options and the tradeoffs; you make the call. That is not a formality — it’s the judgment this module is testing. A documented decision that says “I chose option A because the billing timestamps are always GST+4 with no DST, so the date-fns approximation introduces a silent difference we’d only see twice a year during a clock-change that doesn’t happen in the Gulf” is demonstrating engineering judgment. “I chose option A” is not.
- The decision log is a permanent artefact. After the migration lands, the documented decisions are the reference for anyone who asks “why is this one case still using moment.js?” or “why is this timezone calculated differently from the others?” The PR description summary carries the high-level; the inline comments carry the case-level. Both are permanent, because the decision needs to be traceable forever.
The suite as the arbiter — prove the mechanical pass didn’t break anything
The playbook ends with running the suite and reviewing the complete diff. Mastery is the discipline that makes that final check honest.
- Run the suite on the conversion pass, not just at the end. After the mechanical 90% lands, run the suite before addressing the decided cases. This separates a failure caused by the mechanical pass from a failure caused by a decided case — and makes each category’s correctness verifiable independently.
- Read the diff in the two logical groups. The mechanical sweep and the decided cases are different kinds of changes and require different kinds of review. A reviewer who reads them intermixed can’t tell which kind of change introduced a given issue. Stage the sweep as one commit group and the decisions as another, so the diff structure reflects the conceptual structure.
- A failure in the decided cases is a signal to investigate, not correct. If a decided case goes red, the correct response is to look at why — did the decision introduce the failure, or was there a test that was already wrong, or is the failure pointing at a behavior difference that wasn’t anticipated? Never fix a test to make a migration pass; investigate why it went red.
Your assignment
Complete the full migration loop for one real repo-wide change — your own codebase (recommended) or the Mizan moment.js → date-fns migration worked through this module. Open the project folder in Claude Desktop with your CLAUDE.md in place and work in the chat — running the suite is the Power Track step.
Module 5 deliverable — migration at scale
1. Blast-radius survey (one page)
- total usage count and the grouped breakdown (mechanical / hard / unusual)
- the three most unusual usages named specifically and why they're unusual
- the approach: convert mechanical cases to match [your hand-done example],
leave hard cases flagged with TODO
2. Mechanical conversion (the diff, staged separately from the decisions)
- one concern: the mechanical sweep
- suite run after the sweep (before the decided cases)
3. Documented decisions (one page or inline comments)
- for each hard case: what the problem was, the options and tradeoffs,
what you decided and why (not "I chose option A" — the actual reasoning)
- each decided case as a separate commit from the mechanical sweep
4. Final suite results (green, or the red with an explanation)
How it’s graded — the rubric
Migration rubric
1. Blast-radius survey Total usage count grouped by complexity (mechanical
grouped correctly / hard / unusual). The hard and unusual cases named
specifically before any conversion started.
2. Hard cases flagged, Every case with no clean equivalent is left unchanged
not silently mangled and flagged — not approximated to something that
compiles but behaves differently.
3. Decisions documented Each hard case has: what the problem was, the options
with reasoning considered, and the decision with the actual reasoning
— not just "chose option A."
4. Diff staged in two Mechanical sweep and decided cases are separate commits
logical groups (reviewable independently). Suite run after the sweep,
before the decisions.
5. Suite green at end The full suite is green after all changes. If red:
the failing test named, the investigation shown, and
no test modified to paper over a real signal.
The bar, shown — a worked model answer (Mizan)
Blast-radius survey — Mizan moment.js → date-fns migration
Total: 74 usages across 23 files.
Groups:
- 62 straightforward: .format(), .add(), .subtract(), .isBefore(),
.isAfter(), .diff() — all have clean one-to-one date-fns equivalents.
- 9 hard: .utcOffset() used for Gulf Standard Time calculations in
src/lib/billing.ts and src/lib/invoice-calc.ts — no direct equivalent;
date-fns handles UTC offsets differently from moment's offset model.
- 3 unusual: dynamic import in src/scripts/backfill.ts (moment is
imported inside an async function based on a runtime flag), one usage
in a Playwright test file (src/tests/e2e/invoices.spec.ts), and one
in a migration file (supabase/migrations/20260101_add_invoice_dates.sql
— which uses moment in a SQL comment, not in logic).
Most unusual: the SQL migration file — moment appears in a comment only,
safe to leave as-is (comments don't run). The dynamic import in backfill.ts
requires the import to be moved to the top level after migration.
Hand-done example (criterion): moment().format('YYYY-MM-DD') →
format(new Date(), 'yyyy-MM-dd') [note: date-fns uses lowercase 'y' for
year, lowercase 'd' for day — easy to get wrong].
Documented decision — Mizan billing.ts utcOffset cases
Hard case: src/lib/billing.ts:142–156
Old: moment(invoiceDate).utcOffset('+04:00').format('YYYY-MM-DD')
Problem: date-fns has no utcOffset(); the equivalent is manual offset
arithmetic or using the date-fns-tz library.
Option A: Use date-fns-tz (new dependency).
format(utcToZonedTime(invoiceDate, 'Asia/Dubai'), 'yyyy-MM-dd', { timeZone: 'Asia/Dubai' })
Tradeoff: correct for DST-aware zones, but Gulf Standard Time has no DST,
so this is an unnecessary dependency for our use case.
Option B: Manual UTC+4 offset arithmetic.
const gst = new Date(invoiceDate.getTime() + 4 * 60 * 60 * 1000)
format(gst, 'yyyy-MM-dd')
Tradeoff: simple and correct for GST (no DST), but would silently fail
if we ever serve a timezone with DST without updating this code.
Decision: Option B — manual UTC+4 arithmetic.
Reasoning: Gulf Standard Time is UTC+4 permanently, with no DST offset.
The billing timestamps are generated server-side in our Dubai-region
Supabase instance. Adding date-fns-tz for a permanently-fixed offset
is unnecessary complexity. Added a comment at the call site explaining
why this is safe and what would need to change if we add DST-affected
timezones.
What you’ve proven — and what’s next
Clear the rubric and you’ve demonstrated the discipline that makes a repo-wide migration trustworthy rather than hopeful: a surveyed blast radius, a mechanical pass that converts what it can and honestly flags what it can’t, and decisions that future engineers can trace.
The capstone is next: the full arc of M1–M5 integrated into one complete engineering project on a single codebase, graded against a master rubric, leading to “Certified Engineering with Claude.”