Split View: AI로 코드베이스를 옮기는 실전 절차 — 심판을 먼저 세우고, 줄 수가 아니라 리뷰율을 측정한다
AI로 코드베이스를 옮기는 실전 절차 — 심판을 먼저 세우고, 줄 수가 아니라 리뷰율을 측정한다
- 들어가며 — 순서를 틀리면 규모가 커질수록 손해가 커집니다
- 0단계 — 번역보다 먼저 심판을 세우고, 심판을 먼저 디버깅한다
- 1단계 — 룰북과 의존성 지도, 그리고 3파일 예행 연습
- 2단계 — 검증 가능한 경계로 자르고, 큐를 디스크에 맡긴다
- 3단계 — 무엇을 측정하나: 변환한 줄 수는 진척도가 아니다
- 4단계 — 세 가지 실패 모드와 각각의 탐지법
- 마치며 — 이 절차의 값어치는 속도가 아니라 되돌릴 수 있음에 있습니다
- 참고 자료
들어가며 — 순서를 틀리면 규모가 커질수록 손해가 커집니다
2026년 상반기에 대규모 LLM 마이그레이션 사례가 연달아 공개됐습니다. Bun은 53만 5천 줄의 Zig를 11일 만에 Rust로 옮겼고(발표문), Anthropic의 Mike Krieger는 16만 5천 줄의 Python을 주말 하나 동안 TypeScript로 옮겼습니다(방법론 정리에 실린 사례). 두 사례의 언어도 규모도 팀 구성도 다르지만, 절차는 놀랄 만큼 같습니다.
그리고 그 절차에서 가장 중요한 것은 개별 기법이 아니라 순서입니다. 번역을 먼저 시작하고 검증을 나중에 붙이면, 규모가 커질수록 손해가 기하급수로 커집니다. 100개 파일을 잘못 옮기고 나서 규칙이 틀렸음을 알면 100개를 다시 돌리면 되지만, 1,400개를 옮긴 뒤에 알면 그 시점에는 이미 다음 단계의 작업이 그 위에 쌓여 있습니다.
이 글은 공개된 1차 소스(Anthropic의 마이그레이션 키트와 방법론 문서, Bun 발표문)에서 재현 가능한 절차만 뽑아 순서대로 정리한 것입니다. 특정 모델이나 도구에 묶이지 않는 부분만 남겼고, 검증되지 않은 대목은 그렇다고 적었습니다. Bun 사례 자체에 대한 분석은 별도 글에서 다뤘으니, 여기서는 절차만 봅니다.
0단계 — 번역보다 먼저 심판을 세우고, 심판을 먼저 디버깅한다
가장 흔한 실수는 이 단계를 건너뛰는 것입니다. 마이그레이션은 사람 리뷰를 기계 판정으로 대체해서 속도를 얻습니다. 판정자가 없으면 속도를 얻은 게 아니라 검증을 생략한 것입니다.
동작 오라클은 셋 중 하나 이상으로 만듭니다.
이식 가능한 테스트 스위트가 가장 좋습니다. 조건은 하나 — 테스트가 공개 표면만 건드려야 합니다. 내부 함수를 직접 부르거나 프라이빗 상태를 검사하는 테스트는 언어가 바뀌면 같이 버려야 하므로 오라클이 되지 못합니다. Bun이 11일을 만든 결정적 조건이 여기 있었습니다. 테스트가 TypeScript로 작성돼 런타임 구현 언어와 무관했고, 이식 과정에서 삭제되거나 스킵된 테스트가 0개였습니다.
골든 출력은 테스트가 부실한 레거시에서 두 번째 선택지입니다. 실제 입력 묶음을 정해 옛 구현의 출력을 파일로 고정해 두고, 새 구현의 출력과 바이트 단위로 비교합니다. 배치 잡, 리포트 생성기, 파서처럼 입출력이 결정적인 시스템에서 잘 작동합니다.
차등 실행은 셋 중 가장 강력합니다. 같은 입력을 두 구현에 동시에 흘리고 결과를 비교합니다. Krieger의 사례가 이 형태였는데, 실제 시나리오 7개로 패리티 하네스를 만들고 어떤 동작 변화든 버그로 간주하는 규칙을 세웠습니다. "의도한 개선"이라는 예외를 허용하지 않는 것이 핵심입니다. 예외를 허용하는 순간 오라클이 협상 대상이 됩니다.
그리고 여기서 대부분이 놓치는 단계가 하나 더 있습니다 — 심판을 먼저 디버깅합니다. 오라클을 원본 코드에 돌려 전부 통과하는지, 그리고 일부러 망가뜨린 원본에 돌려 제대로 떨어지는지를 확인해야 합니다. 전부 떨어뜨리는 심판은 대개 옳은 게 아니라 고장 난 것입니다.
# 심판 검증: 통과해야 할 때 통과하고, 떨어져야 할 때 떨어지는가
git stash list >/dev/null 2>&1 || exit 1
# 1) 정상 원본에서 전부 초록이어야 한다
./run-oracle.sh --target=legacy || echo "FAIL: 심판이 원본조차 통과시키지 못한다"
# 2) 일부러 망가뜨린 원본에서는 반드시 빨간불이 떠야 한다
# (비교 연산 하나 뒤집기 같은 최소 변형이면 충분하다)
git apply mutations/flip-one-comparison.patch
./run-oracle.sh --target=legacy && echo "FAIL: 심판이 변형을 못 잡는다. 오라클부터 고쳐야 한다"
git apply -R mutations/flip-one-comparison.patch
이 두 줄을 돌리는 데 드는 시간이, 나중에 "테스트는 다 통과하는데 프로덕션이 깨진다"를 디버깅하는 시간보다 항상 쌉니다.
1단계 — 룰북과 의존성 지도, 그리고 3파일 예행 연습
룰북은 번역 판단을 한 번만 내리기 위한 문서입니다. 키트가 붙인 메타 규칙이 판별 기준으로 그대로 쓸 만합니다 — 두 에이전트가 서로 다르게 답할 수 있는 질문이면 룰북 항목입니다. 오류 처리 관용구 대응, 널 표현, 정수 오버플로 정책, 로깅 포맷, 명명 규칙, 동시성 원시 타입 매핑이 전형적인 항목입니다. Bun의 PORTING.md는 약 600줄이었고, 작성에 든 시간은 코딩 전 약 3시간이었습니다.
의존성 지도는 결정적 스크립트로 만듭니다. 모델에게 "이 파일이 무엇에 의존하나요"를 묻지 마세요. 임포트 구문을 파싱하는 30줄짜리 스크립트가 더 정확하고 재현 가능합니다. 이 지도의 산출물이 작업 순서(리프에서 루트 방향)와 순환 목록입니다. 순환은 미리 알아야 합니다 — 원본 언어가 관대하게 넘긴 순환 의존이 대상 언어에서 수천 개의 모듈 오류로 한꺼번에 터지는 것이 이 작업의 단골 사고입니다.
갭 인벤토리는 대상 언어가 새로 요구하는 정보의 목록입니다. Zig에서 Rust로 갈 때는 소유권과 수명, Python에서 TypeScript로 갈 때는 명시적 인터페이스 계약이 여기 들어갑니다. 원본에 존재하지 않는 정보이므로 번역이 아니라 결정입니다. 결정은 사람이 하고 룰북에 적습니다.
그다음이 예행 연습입니다. 파일 3개만 골라 전체 파이프라인을 돌립니다. 한 에이전트는 룰북을 따라 번역하고, 다른 에이전트는 룰북 없이 그 언어의 시니어처럼 번역하고, 세 번째가 두 결과의 차이를 감사합니다. 여기서 나온 번역물은 전부 버리고 규칙만 고칩니다. Bun은 이 단계에서 두 건을 잡았는데, 그대로 갔다면 1,448개 파일 전체에 번졌을 문제였습니다. 사람 시간을 몰아 넣을 지점이 있다면 여기입니다.
2단계 — 검증 가능한 경계로 자르고, 큐를 디스크에 맡긴다
작업 단위를 고를 때 기준은 크기가 아니라 검증 가능성입니다. 단위 하나를 옮긴 뒤 "됐는지"를 기계가 판정할 수 있어야 합니다. 파일 단위가 잘 맞는 이유는 대상 파일의 존재 여부와 컴파일 성공 여부로 판정이 끝나기 때문입니다.
여기서 구조 보존과 재설계를 갈라야 합니다. 파일 대 파일 대응을 유지하는 구조 보존 이식은 예행 연습, 줄 단위 대조, 파일 단위 큐가 전부 성립합니다. 반면 옮기면서 모듈 경계를 다시 그리기로 하면 이 장치들이 동시에 무너집니다 — 대조할 원본 줄이 없어지고, 단위가 모듈로 커지고, 되돌리기 반경도 같이 커집니다. 옮기기와 다시 그리기를 한 번에 하려는 유혹이 이 절차의 가장 흔한 실패 원인입니다. 순서를 나누세요. 먼저 옮기고, 오라클이 초록인 상태에서 다시 그립니다.
완료 판정은 디스크 상태에 맡깁니다. 이 한 가지 결정이 재개 가능성과 되돌리기 가능성을 동시에 줍니다.
# 큐 = 매니페스트에서 아직 출력 파일이 없는 항목. 그 이상의 상태를 두지 않는다.
awk -F'\t' '{ if (system("[ -f " $2 " ]") != 0) print }' migration/manifest.tsv \
> migration/queue.tsv
wc -l < migration/queue.tsv # 남은 작업량
git worktree list # 워크스트리마다 별도 큐 슬라이스를 준다
진척도를 에이전트에게 묻는 순간 진척도 자체가 모델 출력이 되고 환각의 대상이 됩니다. 파일 존재 여부는 환각하지 않습니다.
되돌리기 가능성은 세 가지로 유지합니다. 단위마다 커밋을 쪼개서 되돌리기 반경을 파일 하나로 유지하고, 번역 단계와 수정 단계를 별도 브랜치나 워크스트리로 분리하고, 룰북 개정 시점을 커밋 메시지에 남깁니다. 마지막 항목이 중요한 이유는, 규칙이 바뀌면 그 이전에 만들어진 산출물을 재생성해야 할 범위를 특정할 수 있어야 하기 때문입니다.
컴파일러를 루프 어디에 둘지도 이 단계에서 정합니다. TypeScript나 Go처럼 타입 체크가 빠르면 번역 루프 안에 넣어 즉시 피드백을 받고, Cargo처럼 느리면 번역이 다 끝난 뒤 한 번에 돌려 오류 목록을 다음 큐로 씁니다. Bun은 후자였고, 약 1,600개의 컴파일 오류를 12시간 만에 걷어냈습니다.
3단계 — 무엇을 측정하나: 변환한 줄 수는 진척도가 아니다
마이그레이션 리포트에서 가장 자주 보이고 가장 쓸모없는 숫자가 "변환한 줄 수"입니다. 이 숫자는 출력량이지 진척도가 아닙니다. 실제로 의사 결정을 바꾸는 지표는 다음 다섯입니다.
| 지표 | 정의 | 무엇을 알려 주나 |
|---|---|---|
| 심판 통과율 | 오라클 케이스 중 새 구현이 통과한 비율 | 유일한 완료 정의. Bun은 972개 실패 파일에서 23개, 다시 0개로 내려갔다 |
| 규칙 위반 재발률 | 리뷰어 지적 중 이미 룰북에 있는 항목의 재위반 비율 | 값이 안 떨어지면 규칙이 아니라 프로세스가 문제다 |
| 디프 리뷰율 | 사람이 실제로 읽은 줄 수 나누기 생성된 줄 수 | 리스크의 실제 크기. 100만 줄이면 이 값은 정직하게 한 자릿수 퍼센트다 |
| 단위당 재작업 횟수 | 파일 하나가 큐에 다시 들어간 평균 횟수 | 2회를 넘기면 룰북이 그 영역을 커버하지 못하고 있다 |
| 잔여 마커 수 | TODO(port), BUG(port) 주석의 남은 개수 | 병합 이후에도 남는 실제 부채. 병합일을 완료일로 착각하지 않게 해 준다 |
이 중 디프 리뷰율을 반드시 계산해서 기록하기를 권합니다. 100만 줄 규모에서 이 값을 정직하게 계산하면 대부분 5퍼센트 아래입니다. 그 숫자가 불편하다면, 불편함이 정확한 반응입니다. 그것이 이 방식이 감수하는 리스크의 크기이고, 의사 결정자에게 보고해야 할 값입니다. 숨기지 말고, 대신 나머지 95퍼센트를 무엇이 판정했는지(컴파일러, 오라클, 적대적 리뷰어)를 같이 적으세요.
리뷰 경제학은 이렇게 정리됩니다. 사람이 읽어야 하는 것은 생성된 코드가 아니라 생성 규칙과 그 규칙이 만든 대표 샘플입니다. 룰북 600줄, 예행 연습 3파일의 디프, 그리고 리뷰어들이 이견을 낸 케이스. 이 셋은 사람이 전부 읽을 수 있고, 읽으면 실제로 결과가 바뀝니다. 반대로 1,448개 파일을 훑는 리뷰는 시간을 쓰고도 결과를 바꾸지 못합니다.
4단계 — 세 가지 실패 모드와 각각의 탐지법
조용한 의미 변형. 컴파일도 되고 테스트도 통과하는데 동작이 미묘하게 다른 경우입니다. 부동소수 반올림, 정렬 안정성, 널과 빈 값의 구분, 오류 전파 순서, 시간대 처리, 정수 오버플로 동작에서 주로 나옵니다. 테스트가 커버하지 않는 경로에서 발생하므로 테스트로는 잡히지 않습니다. 탐지법은 차등 실행 하나뿐입니다 — 프로덕션 트래픽의 샘플이나 실제 입력 로그를 두 구현에 흘리고 출력을 비교하세요. Krieger가 "어떤 동작 변화든 버그"라는 규칙을 세운 이유가 이것입니다. 예외 목록을 만드는 순간 이 실패 모드를 막을 수단이 사라집니다.
환각 API. 존재하지 않는 함수, 잘못된 시그니처, 옛 버전의 인자 순서를 호출하는 코드입니다. 정적 타입 언어로 옮기는 경우에는 컴파일러가 전부 잡아 주므로 실무적으로 큰 문제가 아닙니다. 위험한 것은 대상이 동적 언어일 때입니다. Python이나 Ruby로 옮기면 잘못된 호출이 런타임까지 살아남고, 그 경로에 테스트가 없으면 프로덕션에서 처음 터집니다. 대상이 동적 언어라면 임포트 해석과 시그니처 검사를 별도 정적 분석 단계로 반드시 추가하고, 그 단계를 번역 루프 안에 넣으세요.
엉뚱한 이유로 통과하는 테스트. 셋 중 가장 위험합니다. 대표적인 형태가 셋 있습니다. 첫째, 새 코드를 쓴 것과 같은 에이전트가 테스트도 고친 경우 — 심판과 피고를 겸하게 됩니다. 오라클 파일은 마이그레이션 브랜치에서 쓰기 금지로 두고, 변경이 필요하면 사람 승인 단계를 거치게 하세요. 둘째, 예외를 삼키는 테스트 — 실패 경로가 조용히 통과로 바뀝니다. 셋째, 조기 반환으로 검증부를 건너뛰는 테스트입니다.
# 오라클이 마이그레이션 중에 변형되지 않았는지 매 회차 확인한다
git diff --stat migration-base..HEAD -- tests/ | tail -1
# 통과 개수가 아니라 "실제로 실행된 assertion 수"를 함께 본다.
# 통과 수는 그대로인데 assertion 수가 줄었다면 테스트가 조용히 비었다는 뜻이다.
./run-oracle.sh --report=assertions | tee migration/assertions-$(date +%s).txt
세 실패 모드에 공통된 대응 원칙이 하나 있습니다. 리뷰어가 같은 지적을 세 번 하면 그것은 개별 버그가 아니라 계통 오류이므로, 파일을 고치지 말고 룰북을 고치고 영향받은 배치를 재생성하세요. 개별 파일을 손으로 때우기 시작하면 규칙과 코드가 갈라지고, 그 시점부터 재생성이 불가능해집니다.
마치며 — 이 절차의 값어치는 속도가 아니라 되돌릴 수 있음에 있습니다
정리하면 순서는 이렇습니다.
- 동작 오라클을 만들고, 일부러 망가뜨린 코드로 오라클이 떨어지는지부터 확인합니다.
- 룰북과 의존성 지도, 갭 인벤토리를 만들고 3파일 예행 연습으로 규칙을 두들깁니다. 번역물은 버리고 규칙만 남깁니다.
- 검증 가능한 경계로 잘라 큐를 만들고, 완료 판정을 디스크 상태에 맡겨 중단과 재개를 무료로 만듭니다.
- 변환한 줄 수 대신 심판 통과율, 규칙 위반 재발률, 디프 리뷰율, 단위당 재작업 횟수, 잔여 마커 수를 봅니다.
- 리뷰어의 반복 지적은 파일이 아니라 규칙에 반영하고, 영향 범위를 재생성합니다.
이 절차가 주는 진짜 이득은 속도가 아닙니다. 전부 버리고 다시 돌릴 수 있다는 것입니다. 룰북과 큐와 오라클이 분리돼 있으면 최악의 경우에도 브랜치를 지우고 규칙을 고쳐 재실행하면 됩니다. 몇 년짜리 마이그레이션이 커리어를 걸어야 하는 프로젝트였던 이유는 되돌릴 수 없어서였지, 어려워서가 아니었습니다.
한 가지만 남기자면 이것입니다 — 번역을 시작하기 전에 무엇이 끝을 판정할지 정하지 못했다면, 그 마이그레이션은 아직 시작할 준비가 되지 않았습니다.
참고 자료
- How Anthropic runs large-scale code migrations with Claude Code — 6단계 방법론 원문
- anthropics/code-migration-kit-with-claude-code — 프롬프트 00~06, 룰북 템플릿, 의존성 매퍼, 빌드 데몬
- Rewriting Bun in Rust — 오라클 테스트 스위트와 예행 연습 사례
- Hacker News — Anthropic runs large-scale code migrations 토론(회의론 포함)
- The Pragmatic Engineer — 대규모 AI 마이그레이션의 전제 조건 분석
- Bun의 Zig to Rust 재작성 11일 — 사례 분석 편 (관련 글)
- 리팩터링의 경제학, 언제 비용이 회수되나 (관련 글)
A Practical Procedure for Moving a Codebase with AI — Stand Up the Judge First, and Measure Review Rate Instead of Lines
- Introduction — Get the Order Wrong and the Loss Grows With Scale
- Step 0 — Stand Up the Judge Before Translating, and Debug the Judge First
- Step 1 — The Rulebook, the Dependency Map, and a 3-File Dress Rehearsal
- Step 2 — Cut Along Verifiable Boundaries and Delegate the Queue to Disk
- Step 3 — What to Measure: Lines Converted Is Not Progress
- Step 4 — Three Failure Modes and How to Detect Each
- Closing — The Value of This Procedure Is Not Speed but Reversibility
- References
Introduction — Get the Order Wrong and the Loss Grows With Scale
In the first half of 2026 a run of large-scale LLM migrations was published back to back. Bun moved 535,000 lines of Zig to Rust in 11 days (the announcement), and Anthropic's Mike Krieger moved 165,000 lines of Python to TypeScript over a single weekend (a case carried in a methodology writeup). The two cases differ in language, in scale, and in team composition, yet the procedure is startlingly similar.
And what matters most in that procedure is not any individual technique but the order. Start translating first and bolt verification on afterward, and the loss compounds exponentially with scale. Port 100 files wrong and then discover the rules were wrong, and you can just rerun 100; discover it after 1,400, and by that point the next stage of work is already stacked on top.
This post takes only the reproducible parts of the published primary sources (Anthropic's migration kit and the methodology document, plus the Bun announcement) and lays them out in order. I kept only the parts that are not tied to a particular model or tool, and where something is unverified I said so. The analysis of the Bun case itself is covered in a separate post, so here we look only at the procedure.
Step 0 — Stand Up the Judge Before Translating, and Debug the Judge First
The most common mistake is skipping this step. A migration buys speed by replacing human review with machine adjudication. Without an adjudicator you have not bought speed; you have simply omitted verification.
Build the behavioral oracle from at least one of three things.
A portable test suite is the best option. There is one condition — the tests must touch only the public surface. Tests that call internal functions directly or inspect private state have to be thrown away along with the language, so they cannot serve as an oracle. This is where Bun's 11 days came from. The tests were written in TypeScript and therefore independent of the runtime's implementation language, and zero tests were deleted or skipped during the port.
Golden outputs are the second choice on legacy where the tests are thin. Fix a bundle of real inputs, freeze the old implementation's output to a file, and compare the new implementation's output byte for byte. It works well for systems with deterministic input and output — batch jobs, report generators, parsers.
Differential runs are the strongest of the three. You push the same input through both implementations at once and compare the results. Krieger's case took this form: he built a parity harness out of 7 real scenarios and set the rule that any behavioral change counts as a bug. Refusing to allow an "intended improvement" exception is the crux. The moment you allow an exception, the oracle becomes negotiable.
And there is one more step here that most people miss — debug the judge first. You have to run the oracle against the original code and confirm everything passes, then run it against a deliberately broken original and confirm it properly fails. A judge that fails everything is usually not right; it is broken.
# Judge verification: does it pass when it should pass, and fail when it should fail?
git stash list >/dev/null 2>&1 || exit 1
# 1) On the intact original, everything must be green
./run-oracle.sh --target=legacy || echo "FAIL: the judge cannot even pass the original"
# 2) On a deliberately broken original, a red light must appear
# (a minimal mutation such as flipping one comparison is enough)
git apply mutations/flip-one-comparison.patch
./run-oracle.sh --target=legacy && echo "FAIL: the judge does not catch the mutation. fix the oracle first"
git apply -R mutations/flip-one-comparison.patch
The time it costs to run those two lines is always cheaper than the time you would later spend debugging "all the tests pass but production is broken."
Step 1 — The Rulebook, the Dependency Map, and a 3-File Dress Rehearsal
The rulebook is a document whose purpose is to make each translation judgment exactly once. The meta-rule the kit attaches works fine as the criterion for what belongs in it — if two agents could answer a question differently, that question is a rulebook entry. Mapping error-handling idioms, representing null, integer overflow policy, logging format, naming conventions, and concurrency primitive mappings are the typical entries. Bun's PORTING.md was around 600 lines, and it took roughly 3 hours before any coding began.
The dependency map is built with a deterministic script. Do not ask the model "what does this file depend on." A 30-line script that parses import statements is more accurate and reproducible. What this map produces is the work order (leaves toward the root) and the list of cycles. You need to know the cycles in advance — a circular dependency the source language tolerated blowing up all at once as thousands of module errors in the target language is the classic accident of this kind of work.
The gap inventory is the list of information the target language newly demands. Going from Zig to Rust, ownership and lifetimes go here; from Python to TypeScript, explicit interface contracts. This is information that does not exist in the source, so it is not translation but decision. Humans make the decisions and write them into the rulebook.
Then comes the dress rehearsal. Pick just 3 files and run the whole pipeline. One agent translates by following the rulebook, another translates without it as a senior in that language would, and a third audits the difference between the two results. Throw away every translation produced here and fix only the rules. Bun caught two issues at this stage that would have spread across all 1,448 files had they gone forward. If there is one place to pour human time into, it is here.
Step 2 — Cut Along Verifiable Boundaries and Delegate the Queue to Disk
When choosing the unit of work, the criterion is not size but verifiability. After porting one unit, a machine has to be able to adjudicate whether it "worked." The reason file-level units fit well is that the adjudication is over as soon as you check whether the target file exists and whether it compiles.
Here you have to split structure-preserving porting from redesign. A structure-preserving port that maintains file-to-file correspondence keeps the dress rehearsal, line-by-line comparison, and the file-level queue all intact. Decide instead to redraw module boundaries while you move, and those devices collapse simultaneously — there are no source lines to compare against, the unit swells to a module, and the revert radius swells with it. The temptation to move and redraw in a single pass is the most common cause of failure in this procedure. Separate the order. Move first, then redraw with the oracle green.
Delegate the done-check to disk state. This one decision gives you resumability and revertibility at the same time.
# Queue = entries in the manifest that do not yet have an output file. Keep no state beyond that.
awk -F'\t' '{ if (system("[ -f " $2 " ]") != 0) print }' migration/manifest.tsv \
> migration/queue.tsv
wc -l < migration/queue.tsv # work remaining
git worktree list # give each worktree its own queue slice
The moment you ask the agent for progress, progress itself becomes model output and therefore subject to hallucination. File existence does not hallucinate.
Maintain revertibility three ways. Split commits per unit so the revert radius stays at a single file, separate the translation stage from the fix-up stage into different branches or worktrees, and record the point at which the rulebook was revised in the commit message. That last item matters because when the rules change, you have to be able to pin down the scope that must be regenerated among artifacts produced before that point.
Where the compiler sits in the loop is also decided at this stage. When type checking is fast, as in TypeScript or Go, put it inside the translation loop for immediate feedback; when it is slow, as with Cargo, run it once after translation finishes and use the error list as the next queue. Bun took the latter route and cleared roughly 1,600 compile errors in 12 hours.
Step 3 — What to Measure: Lines Converted Is Not Progress
The number that shows up most often in migration reports and is the most useless is "lines converted." That number is output volume, not progress. The metrics that actually change decisions are these five.
| Metric | Definition | What it tells you |
|---|---|---|
| Judge pass rate | Share of oracle cases the new implementation passes | The only definition of done. Bun went from 972 failing files to 23, then to 0 |
| Rule recidivism | Share of reviewer findings that were already covered by the rulebook | If it does not fall, the problem is the process, not the rules |
| Diff review rate | Lines a human actually read divided by lines generated | The true size of the risk. At a million lines this is honestly a single-digit percentage |
| Rework per unit | Average number of times a single file re-entered the queue | Past 2 rounds, the rulebook is not covering that area |
| Residual markers | Remaining count of TODO(port) and BUG(port) comments | The real debt that survives the merge. It keeps you from mistaking merge day for done day |
Of these I strongly recommend calculating and recording the diff review rate. Compute it honestly at the million-line scale and it is usually under 5 percent. If that number is uncomfortable, the discomfort is the correct reaction. It is the size of the risk this approach accepts, and it is a value you owe to the decision-makers. Do not hide it; instead, write alongside it what adjudicated the other 95 percent — the compiler, the oracle, the adversarial reviewer.
The economics of review come out like this. What a human has to read is not the generated code but the generation rules and the representative samples those rules produced. Six hundred lines of rulebook, the diffs of the 3 dress-rehearsal files, and the cases where the reviewers disagreed. A human can read all three of those, and reading them actually changes the outcome. A review that skims 1,448 files, by contrast, spends the time without changing the outcome.
Step 4 — Three Failure Modes and How to Detect Each
Silent semantic drift. It compiles and the tests pass, but the behavior is subtly different. It mainly shows up in floating-point rounding, sort stability, the distinction between null and empty, the order of error propagation, time zone handling, and integer overflow behavior. Because it occurs on paths the tests do not cover, tests will not catch it. There is exactly one detection method — differential runs. Push a sample of production traffic or a log of real inputs through both implementations and compare the outputs. This is why Krieger set the rule that "any behavioral change is a bug." The moment you start an exception list, your means of stopping this failure mode disappears.
Hallucinated APIs. Code that calls nonexistent functions, wrong signatures, or the argument order of an old version. When you are moving to a statically typed language the compiler catches all of it, so in practice it is not a big problem. What is dangerous is when the target is a dynamic language. Move to Python or Ruby and the bad call survives to runtime, and if there is no test on that path it first blows up in production. If the target is dynamic, you must add import resolution and signature checking as a separate static analysis stage, and put that stage inside the translation loop.
Tests that pass for the wrong reason. The most dangerous of the three. There are three representative forms. First, the same agent that wrote the new code also fixed the tests — it ends up serving as both judge and defendant. Keep oracle files write-protected on the migration branch, and route any needed change through a human approval step. Second, tests that swallow exceptions — the failure path quietly turns into a pass. Third, tests that skip the assertion section via an early return.
# Check every round that the oracle was not mutated during the migration
git diff --stat migration-base..HEAD -- tests/ | tail -1
# Look not at the pass count but at the "number of assertions actually executed" alongside it.
# If the pass count held steady while the assertion count dropped, a test quietly went empty.
./run-oracle.sh --report=assertions | tee migration/assertions-$(date +%s).txt
There is one response principle common to all three failure modes. If a reviewer makes the same finding three times, that is not an individual bug but a systematic error, so do not fix the file — fix the rulebook and regenerate the affected batch. Start patching individual files by hand and the rules and the code diverge, and from that point on regeneration becomes impossible.
Closing — The Value of This Procedure Is Not Speed but Reversibility
To sum up, the order is this.
- Build a behavioral oracle, and first confirm that the oracle fails on deliberately broken code.
- Build the rulebook, the dependency map, and the gap inventory, and hammer on the rules with a 3-file dress rehearsal. Throw the translations away and keep only the rules.
- Cut along verifiable boundaries into a queue, and delegate the done-check to disk state so that stopping and resuming are free.
- Instead of lines converted, watch judge pass rate, rule recidivism, diff review rate, rework per unit, and residual marker count.
- Reflect a reviewer's repeated findings in the rules rather than in the files, and regenerate the affected scope.
The real benefit this procedure gives you is not speed. It is that you can throw it all away and run it again. With the rulebook, the queue, and the oracle kept separate, even in the worst case you delete the branch, fix the rules, and rerun. The reason a multi-year migration used to be a project you bet your career on was that it was irreversible, not that it was hard.
If you keep only one thing, keep this — if you have not decided what will adjudicate the end before you begin translating, that migration is not yet ready to start.
References
- How Anthropic runs large-scale code migrations with Claude Code — the original 6-step methodology
- anthropics/code-migration-kit-with-claude-code — prompts 00 through 06, rulebook template, dependency mapper, build daemon
- Rewriting Bun in Rust — the oracle test suite and dress rehearsal case
- Hacker News — discussion of Anthropic running large-scale code migrations (skepticism included)
- The Pragmatic Engineer — an analysis of the preconditions for large-scale AI migration
- Bun's 11-day Zig to Rust rewrite — the case analysis (related post)
- The economics of refactoring, when the cost is recovered (related post)