How CodeMorph verifies every migrated file
A walk through the migration engine, from the dependency graph and Tarjan's algorithm to the convert, check and repair loop that decides when a batch is verified.
Jephthah Ndukwe
· 5 min read
"Verified" is the word on our homepage, so it had better mean something precise. This post walks through how CodeMorph's migration engine works, what we check, in what order, and what we deliberately refuse to do.
What "verified" means
A migration is verified when, across the whole project:
tsc --noEmitreports zero errors, using your owntsconfig.json.- Your test command exits successfully.
- Your build succeeds.
All three, every time. Not "the new files compile", not a model's confidence score. The rest of this post is about making that bar reachable.
Step 1: build the graph
You can't migrate in the right order without knowing the order. So the first thing CodeMorph does is parse every source file and build a dependency graph.
We parse with tree-sitter rather than the TypeScript compiler. Tree-sitter is fast and error-tolerant: a JavaScript file with a syntax error still produces a usable tree, and we record that the file has errors rather than failing the whole scan. Real codebases always have a few of those.
From each tree we extract imports, exports and declarations, then resolve every import the way your toolchain would: relative paths with extension probing, index files, path aliases from tsconfig.json or jsconfig.json, TS-style .js specifiers that point at .ts files, re-export chains and barrels, CommonJS require(), and dynamic import().
Every import becomes an edge, classified as one of five kinds:
| Kind | Meaning |
|---|---|
file | Another source file in your project |
builtin | A Node.js built-in like fs or node:path |
package | A dependency declared in package.json |
asset | CSS, JSON, images and other non-code imports |
unresolved | Not found, or an undeclared package, with the reason |
Unresolved edges matter. A migration plan built on a graph with silent holes would order files wrongly, so we surface them instead.
The graph, along with every file, symbol and edge, goes into a local SQLite database. Nothing leaves your machine.
Step 2: order the work
Types flow upward through imports, so the plan should start at the leaves: files that import nothing local. In a graph without cycles, that's a topological sort.
Real codebases have cycles. a.js imports b.js, which imports a.js. Neither can be typed correctly without the other, so they must migrate together.
That's exactly what strongly connected components capture. We run Tarjan's algorithm over the file-to-file import graph. It has a convenient property: a component is only emitted after every component it depends on has been emitted. So the output order is already leaf-first, with every cycle collapsed into a single unit.
The last step is packing components into batches. Here's the core of it, lightly trimmed from the shipping code:
const TARGET_BATCH_SIZE = 10;
const components = topoOrder(store, jsFiles.map((f) => f.id));
const batches: MigrationBatch[] = [];
let current: string[] = [];
for (const component of components) {
// A component is never split: a cycle must migrate together.
current.push(...component.map((id) => pathById.get(id)!));
if (current.length >= TARGET_BATCH_SIZE) {
batches.push({ index: batches.length + 1, files: current });
current = [];
}
}
if (current.length > 0) batches.push({ index: batches.length + 1, files: current });Batch size is about pacing and reviewability, not correctness. Any batch size gives a valid order; ten files is a size a person can review in one sitting.
Step 3: measure the baseline
Before converting anything, CodeMorph runs tsc over your JavaScript with --checkJs and counts the errors. On a typical untyped codebase this number is large, often in the thousands.
The baseline does two jobs. It tells you honestly how far there is to go, and it gives every later step a reference point: a batch should never make the project's error picture worse.
Step 4: convert, check, repair
Now the loop. For each batch, in plan order:
for each file in batch:
convert file, with its already-typed dependencies in context
repeat up to N times:
run tsc --noEmit on the whole project
if zero errors: break
send the exact diagnostics for the failing files back for repair
if errors remain:
flag those files "needs review" and stop the batch
run the test command
if tests fail: flag the batch, show the failures
otherwise: batch verifiedA few details make this work.
Context comes from the graph. When models/order.js converts, its dependencies lib/db.ts and utils/money.ts are already TypeScript and already verified. Their real signatures go into the context. The model doesn't have to guess what db.find() returns; it can read it.
We check the whole project, not just the batch. Converting a file can break the files that import it, even ones that are still JavaScript. Type-checking only the new files would miss exactly the errors that matter.
Repairs get the compiler's words. We don't ask the model to "fix the types". We hand it the diagnostics tsc produced, with file, line and message. Compiler output is the most precise bug report there is.
Attempts are bounded. If a file still fails after a few repair rounds, more rounds rarely help. It's flagged for a human, with the remaining diagnostics attached.
What we refuse to do
The easiest way to reach zero type errors is to cheat. We don't.
- No silent
any. A repair that only passes by widening a type toanyis flagged, so you can see every place the types gave up. - No
@ts-ignoreor@ts-expect-errorto force a pass. - No behavior changes. Conversions add types and modernize module syntax where safe. If a test fails, the batch isn't verified, full stop.
Why tests are the last word
Type-checking proves the types are consistent. It doesn't prove they're true. A function annotated as returning string that actually returns undefined on one path will type-check happily if every caller trusts the annotation.
Your test suite is the closest thing your project has to a specification of real behavior. So a batch that type-checks but fails a test isn't verified. And when a project has no tests, CodeMorph says so plainly rather than implying a guarantee it can't give.
Try it on your code
Download the beta, open a project and press Ctrl Shift M. You'll see the graph, the cycles and the batch plan described above, computed for your own code, then watch each batch convert, repair and verify. The first migration guide walks through it.