Codebase-wide change: codemods and ratchets
Write the edit as a program, ship it by owner, and keep a committed count that can only fall.
Predict first
Before you read: predict
ExamHall is replacing the deprecated hook useExamTimer() with useServerDeadline(); @examhall/timing exports both. MR !5270 runs jscodeshift 17.4.0 over apps/ with the transform codemods/use-server-deadline.cjs, shown below the question.
(1) apps/candidate/src/Countdown.tsx:
import { useExamTimer } from '@examhall/timing';
export function Countdown({ attemptId }: { attemptId: string }) {
const { remaining } = useExamTimer(attemptId);
return <span>{remaining}</span>;
}
(2) apps/review/src/ReplayClock.tsx:
function useExamTimer(log: number[]) {
return { remaining: log.length };
}
export function ReplayClock({ log }: { log: number[] }) {
return <i>{useExamTimer(log).remaining}</i>;
}
(3) apps/proctor/src/StatusTile.tsx:
import { formatRemaining, useExamTimer as useTimer } from '@examhall/timing';
export function StatusTile({ attemptId }: { attemptId: string }) {
return <b>{formatRemaining(useTimer(attemptId).remaining)}</b>;
}
(4) apps/candidate/src/Help.tsx:
import { formatRemaining } from '@examhall/timing';
// This page stopped calling useExamTimer() last term.
export const Help = () => <p>{formatRemaining(0)} left</p>;
Which of the four files does the run change?
module.exports = function transform(file, { jscodeshift: j }) { const root = j(file.source); let changed = false; root.find(j.ImportDeclaration, { source: { value: '@examhall/timing' } }) .find(j.ImportSpecifier, { imported: { name: 'useExamTimer' } }) .forEach((spec) => { const local = spec.node.local.name; spec.node.imported = j.identifier('useServerDeadline'); if (local === 'useExamTimer') { spec.node.local = j.identifier('useServerDeadline'); root.find(j.CallExpression, { callee: { name: local } }) .forEach((call) => { call.node.callee = j.identifier('useServerDeadline'); }); } changed = true; }); return changed ? root.toSource() : undefined;};module.exports.parser = 'tsx';The 30-second version
A change too large for one review lands on trunk as many small merge requests. A codemod writes them: it parses each file, edits the syntax tree and prints the file back, split into one merge request per owning team. A ratchet keeps a committed count of the old pattern and fails CI when the count grows.
Write the edit as a program, ship it by owner, and keep a committed count that can only fall.
How it works
Step 1 of 14
se-trunk-based-development step 4).Analogy: A university estates team replacing every wayfinding sign for a new building-numbering scheme
| On the campus | In the repository |
|---|---|
| the sign crew works from the estates register's list of signs by building, not from every board that mentions "Old Library", the café's name | follow the binding, not the text (step 3) |
| a sign on a listed building waits for planning consent | leave what needs a person (step 5) |
| each faculty's facilities officer checks the signs in her own buildings | shard by owner (step 7) |
| the board in the estates office shows how many old-scheme signs are left; the number may fall, and no new sign may be printed in the old scheme | a ratchet (steps 9–10) |
In real terms: generate the edit with a codemod, merge it team by team on trunk, and let a committed count refuse the old pattern.
Breaks down when: nobody puts up a wrong sign by accident in a minute, while a developer can copy an old call from the file next door.
Contrasting case: a village hall with one sign, repainted by one volunteer in an afternoon.
Examples, run for real
4.1 ExamHall EXH-4970: 180 components on the client-side countdown
EXH-4970: useExamTimer() counted down on each candidate's device, so a sleeping laptop woke with minutes the server had already spent. MR !5270 lands a Betterer regexp test for useExamTimer( first, then runs the opening question's transform over the 180 components and opens one merge request per app. The same fixture tree, before and after the four merge requests:
4.1 EXH-4970 before: 206 files under apps/ · ratchet 180 issues
4.1 MR !5270 jscodeshift: 180 ok · 26 skipped · 0 unmodified · 0 errors · one merge request per app: candidate, proctor, review, admin
4.1 after the four merge requests: ratchet 0 issues · betterer exit 0
The run skipped the files that never import the hook, and each app's merge request held only that app's files (steps 7–10).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
A box office is moving from reserveSeat(seatId, minutes) to holdSeat({ seatId, minutes }); @box/seats exports both. A Betterer 5.4.0 test counts the old calls.
.betterer.js:
const { regexp } = require('@betterer/regexp');
module.exports = { 'no new reserveSeat calls': () => regexp(/reserveSeat\(/g).include('./apps/**/*.ts') };
The team runs jscodeshift 17.4.0 over apps/ with the transform codemods/hold-seat.cjs, shown below the question.
apps/checkout/src/basket.ts:
import { reserveSeat } from '@box/seats';
export const holdPair = (a: string, b: string) => [reserveSeat(a, 10), reserveSeat(b, 10)];
apps/checkout/src/express.ts:
import { reserveSeat } from '@box/seats';
export const quick = (seatId: string) => reserveSeat(seatId);
export const timed = (seatId: string) => reserveSeat(seatId, 5);
apps/kiosk/src/walkup.ts:
import { reserveSeat } from '@box/seats';
export const fromPick = (pick: [string, number]) => reserveSeat(...pick);
export const walkUp = (seatId: string) => reserveSeat(seatId, 15);
apps/kiosk/src/lobby.ts:
import { reserveSeat } from '@box/seats';
export const lobby = (seats: string[]) => seats.map((s) => reserveSeat(s));
export const vip = (seatId: string) => reserveSeat(seatId);
apps/refunds/src/rebook.ts:
import { reserveSeat } from '@box/seats';
export const rebook = (oldSeat: string) => reserveSeat(oldSeat, 30);
Every file the run changes is merged, and then npx betterer updates .betterer.results.
How many issues does the results file record?
module.exports = function transform(file, { jscodeshift: j }) { const root = j(file.source); const specs = root.find(j.ImportDeclaration, { source: { value: '@box/seats' } }) .find(j.ImportSpecifier, { imported: { name: 'reserveSeat' } }); if (specs.size() === 0) return undefined; const calls = root.find(j.CallExpression, { callee: { name: 'reserveSeat' } }) .filter((call) => call.node.arguments.length === 2 && call.node.arguments.every((a) => a.type !== 'SpreadElement')); calls.forEach((call) => { const [seatId, minutes] = call.node.arguments; call.node.callee = j.identifier('holdSeat'); call.node.arguments = [j.objectExpression([j.property('init', j.identifier('seatId'), seatId), j.property('init', j.identifier('minutes'), minutes)])]; }); if (calls.size() === 0) return undefined; const left = root.find(j.CallExpression, { callee: { name: 'reserveSeat' } }).size(); if (left === 0) specs.replaceWith(j.importSpecifier(j.identifier('holdSeat'))); else specs.insertAfter(j.importSpecifier(j.identifier('holdSeat'))); return root.toSource();};module.exports.parser = 'ts';Question 2 of 7
The box office's monorepo maps @box/seats to packages/seats/src/index.ts, which the platform team owns. Three app teams own apps/checkout, apps/kiosk and apps/refunds.
packages/seats/src/index.ts:
export function reserveSeat(seatId: string, minutes: number): string {
return `${seatId}:${minutes}`;
}
The platform team's new function:
export function holdSeat(hold: { seatId: string; minutes: number }): string {
return `${hold.seatId}:${hold.minutes}`;
}
apps/checkout/src/pay.ts:
import { reserveSeat } from '@box/seats';
export const pay = (seatId: string) => reserveSeat(seatId, 10);
apps/kiosk/src/walkup.ts:
import { reserveSeat } from '@box/seats';
export const walkUp = (seatId: string) => reserveSeat(seatId, 15);
apps/refunds/src/rebook.ts:
import { reserveSeat } from '@box/seats';
export const rebook = (seatId: string) => reserveSeat(seatId, 30);
For each app, the codemod rewrites the calls as holdSeat({ seatId, minutes }) and the import with them. Three plans:
Ana's plan. The platform team's merge request adds holdSeat and keeps reserveSeat. Once it has merged, the codemod writes one merge request per app from main. Once all three have merged, a last platform merge request deletes reserveSeat.
Ben's plan. The same day, from today's main, the platform team opens a merge request adding holdSeat and the codemod writes one merge request per app. The four merge as their reviews finish. Once all have merged, a last platform merge request deletes reserveSeat.
Cai's plan. As Ana's, except that the codemod writes two merge requests per app, to keep each review small: one converting the calls, one changing the import.
CI type-checks main with TypeScript 6.0.3 after every merge, and each team merges when its review finishes, in no set order.
Whose plan keeps main type-checking after every merge, whatever that order?
Question 3 of 7
The box office's Betterer test is the one from P1, and its committed .betterer.results lists five issues: three in apps/checkout/src/basket.ts and two in apps/refunds/src/rebook.ts. No other file under apps/ calls reserveSeat; apps/kiosk/src/walkup.ts exists and calls only holdSeat. A pre-commit hook runs npx betterer precommit, and a commit goes in only if it exits 0; a refused commit is dropped, and the next starts from the last commit that went in. Four commits are tried in this order:
1 checkout converts two of basket.ts's three reserveSeat calls to holdSeat
2 kiosk adds one reserveSeat call to walkup.ts
3 refunds converts both reserveSeat calls in rebook.ts, and adds apps/kiosk/src/upsell.ts with one reserveSeat call
4 checkout adds one reserveSeat call to basket.ts
Which commits does the hook refuse?
Question 4 of 7
On the box office's main, .betterer.results is committed and matches the code. Lena converts four reserveSeat calls in apps/kiosk, runs npx betterer (it exits 0), then runs git commit apps/kiosk and pushes; her machine has no pre-commit hook. CI runs npx betterer ci.
What does CI do?
Question 5 of 7
The interviewer says: "Write the ratchet yourself. checkRatchet(counts, baselinePath): counts maps each file to today's number of old calls in it, and baselinePath is a JSON file of the same shape. If any file has more calls than its recorded count (a file the baseline does not list counts as 0), return { ok: false, grew, written: false } with the files that grew, sorted, and write nothing. Otherwise write today's non-zero counts if they differ from the file or there is no file yet, and return { ok: true, grew: [], written }." You have 15 minutes, a blank ratchet.mjs and node:fs.
Write checkRatchet in ratchet.mjs: what is your implementation?
Question 6 of 7
A payments-ledger team is replacing toCents(amount) with toMinorUnits(amount); @ledger/money exports both. On Monday its transform ran over apps/ from main, and one merge request per app was opened: ledger with apps/ledger/src/post.ts, payouts with apps/payouts/src/batch.ts, and reports with apps/reports/src/monthly.ts and apps/reports/src/annual.ts. By Wednesday the ledger and payouts merge requests have merged. Two other merge requests also reached main on Tuesday: one added apps/payouts/src/fees.ts, which imports toCents from @ledger/money and calls it once, and one edited a line of apps/reports/src/monthly.ts that the reports merge request also changed, so that merge request now conflicts. On Wednesday the author closes the reports merge request and reruns the same transform over apps/ from main: codemods/minor-units.cjs, shown below the question.
Which files does Wednesday's run change?
module.exports = function transform(file, { jscodeshift: j }) { const root = j(file.source); const specs = root.find(j.ImportDeclaration, { source: { value: '@ledger/money' } }) .find(j.ImportSpecifier, { imported: { name: 'toCents' } }); if (specs.size() === 0) return undefined; specs.replaceWith(j.importSpecifier(j.identifier('toMinorUnits'))); root.find(j.CallExpression, { callee: { name: 'toCents' } }) .forEach((call) => { call.node.callee = j.identifier('toMinorUnits'); }); return root.toSource();};module.exports.parser = 'ts';Question 7 of 7
A chat app is moving off useTypingStatus(). Its Betterer test counts useTypingStatus( under apps/, its committed .betterer.results matches the code with six issues, a pre-commit hook runs npx betterer precommit (a commit goes in only if it exits 0), and CI runs npx betterer ci. Omar's commit adds two useTypingStatus calls to apps/web/src/Composer.tsx, and the hook refuses it. He runs npx betterer --update, commits again with every changed file, and pushes.
What happens?
Go deeper
Pro tips
- Dry-run the transform first:
jscodeshift --dry --printprints every file it would write and changes none, so a sample can be read before any merge request is opened. When: before generating shards (steps 2, 4).
tip 1 --dry --print: Results 180 ok · files written 0 · transformed files printed 180 · without --dry: files written 180
Source
- jscodeshift README (the row's second source): https://github.com/facebook/jscodeshift (
README.mdonmain; byte-identical to the README of the 17.4.0 npm package, https://unpkg.com/[email protected]/README.md) · fetched 2026-10-17 withcurl. "jscodeshift is a toolkit for running codemods over multiple JavaScript or TypeScript files." (step 2); "Recast is an AST-to-AST transform tool and also tries to preserve the style of original code as much as possible." (steps 2, 4); "If nothing is returned, the file is not supposed to be transformed (which is ok)." (step 5). Valid as of 2026-10-17. - Recast README: https://github.com/benjamn/recast (
README.mdonmaster) · fetched 2026-10-17. "The magic of Recast is that it reprints only those parts of the syntax tree that you modify." (step 4). Not a row source: the library jscodeshift wraps, cited for teaching only. Valid as of 2026-10-17. - Betterer docs (the row's third source), at tag v5.4.0, the version the scripts run: https://github.com/phenomnomnominal/betterer (
website/docs/introduction.md,running-betterer.md,results-file.md,regexp-test.md,updating-results.md) · fetched 2026-10-17 (sources/betterer-*.txt). "Whenever your tests run again, Betterer will compare the new result against the expected result. If it gets better, the.betterer.resultsfile will be updated with the new result ✅! If it gets worse, your test will fail and Betterer will throw an error ❌!" (steps 9–10); "Use this test to incrementally remove RegExp matches from your codebase." and "relativeFilePath:fileHash" (step 11); "If there is any difference between the new results and the expected results, then the committed results file doesn't reflect the real state of the codebase, and Betterer will throw an error ❌." and "if there is a change in the results file then Betterer will add it to the commit." (step 12); "Update the results file, even if things get worse" (step 13). Valid as of 2026-10-17 for 5.4.0. - Software Engineering at Google, ch. 22 "Large-Scale Changes" (the row's first source): https://abseil.io/resources/swe-book/html/ch22.html · fetched 2026-10-17. "an LSC is any set of changes that are logically related but cannot practically be submitted as a single atomic unit." (step 1); "The change generation process should be as automated as possible so that the parent change can be updated as users backslide into old uses" (step 6); "Rosie takes a large change and shards it based upon project boundaries and ownership rules into changes that can be submitted atomically." (step 7); "For any LSC process, individual shards should be committable independently." (step 8); "it’s important to have a system that prevents additional introductions of the symbol or system that the large-scale change worked hard to remove." (steps 9, 14). A book: cited for teaching only; it keys nothing. Valid as of 2026-10-17.
Related lessons
Checked against the tools and docs as of 2026-10-17.