Skip to content
CodeMorph
Blog

Inside Scan & Plan: building a dependency graph for messy JavaScript

How CodeMorph walks, parses and resolves a real-world JavaScript project into a queryable graph in seconds, and the decisions that make it deterministic.

Jephthah Ndukwe

· 5 min read

Every CodeMorph migration starts with the same question: how does this codebase actually fit together? Not how the folders are named, or how the architecture diagram says it works, but which file imports which, through which path, under which module system.

Scan & Plan answers that question locally, in seconds, and without changing a file. This post walks through how it works, layer by layer, and the trade-offs behind each choice.

Layer 1: finding the files

The walker starts at the workspace folder and collects every .js, .jsx, .mjs, .cjs, .ts and .tsx file. Two rules keep the graph honest:

Respect .gitignore, all of them. A real repository has a .gitignore at the root and often more in subfolders. Patterns in a nested .gitignore are anchored to that folder, so before applying them we rewrite each pattern relative to the repository root. Get this wrong and node_modules or a build folder floods the graph with thousands of files nobody wrote.

Skip anything over 1 MB. Files that big are almost always bundles, minified vendor code or generated output. Parsing them is slow and the results are noise. They're recorded as skipped, so nothing disappears silently.

Layer 2: parsing with tree-sitter

We parse with tree-sitter rather than the TypeScript compiler. Three reasons:

  1. Error tolerance. A file with a syntax error still produces a tree. Real codebases always have a few broken files, and a migration tool that refuses to start because of one is useless. We record that the file has errors and carry on.
  2. Speed. tree-sitter is built for editors, so it's fast enough to parse a whole project before you've finished reading the first line of output.
  3. One parser, many dialects. JavaScript, JSX, TypeScript and TSX grammars share a shape, so extraction logic doesn't fork per language.

From each tree we extract imports, exports and declarations. That sounds simple until you list the forms that appear in real code:

import x from "./a";
import * as ns from "./b";
import { y as z } from "@/c";
import type { T } from "./types";
import "./polyfill";
const lazy = await import("./d");
const { e } = require("./e");
const f = require("./f").default;
module.exports = { g };
exports.h = h;
export * from "./i";
export { j } from "./j";

Each of these becomes an edge with flags: dynamic, type-only, side-effect only, re-export. Each also records exactly which names cross the boundary. That detail matters later. A type-only import doesn't affect runtime behavior, and knowing which names a file uses from a barrel lets us follow export * chains to the file that really defines a symbol.

Layer 3: resolution, deterministically

Resolution turns a specifier like ../lib/db or @/utils/money into a file. The rules mirror what Node, bundlers and TypeScript do, in a fixed order where the first match wins:

  1. Path aliases from compilerOptions.paths, rebased on baseUrl.
  2. The exact path.
  3. Extensions in a fixed order: .ts, .tsx, .js, .jsx, .mjs, .cjs.
  4. The same extensions under /index.
  5. TypeScript's NodeNext convention, where ./dep.js may refer to dep.ts.

"First match wins" is a deliberate choice. There's no "ambiguous" outcome to handle downstream, and the same project always produces the same graph. Putting .ts before .js also means that during a migration, a converted file is preferred over the JavaScript it replaced.

Anything that isn't a local file gets classified: a Node built-in (with or without the node: prefix), a package declared in package.json (including scoped packages and subpaths), an asset like CSS or JSON, or unresolved, with a reason. Unresolved edges are kept, not dropped. A plan built on a graph with silent holes would order files wrongly, and you'd never know why.

Layer 4: a graph you can query

Everything lands in a small SQLite schema: files, symbols, edges, the names on each edge, packages and unresolved imports. Files carry a content hash, line count and flags for tests (.test., .spec., __tests__/) and entry points (from main and module in package.json).

Storing the graph instead of holding it in memory pays off twice. Morph can query it when it needs context ("what imports models/user?"), and a paused migration can resume without rebuilding anything.

Layer 5: the plan

With the graph built, planning is pure computation.

We run Tarjan's strongly connected components algorithm over the file-to-file edges. It has a property that's easy to miss and extremely convenient: it emits a component only after every component it depends on has been emitted. So its natural output order is already leaf-first, with every cycle collapsed into a single unit that must migrate together.

Then we pack components into batches of about ten files, never splitting a component. Batch size is about pacing and review, not correctness. Any batch size yields a valid order; ten files is what a person can comfortably review in one sitting.

Only JavaScript files go into batches. Existing TypeScript is already typed and simply counts toward the total.

What the plan tells you before you migrate

The plan is useful even if you never convert a file. A tiny first batch means most of the codebase leans on a few core modules. A huge cycle is a design smell worth fixing first. A long list of unresolved imports points at missing dependencies or broken aliases. All of it, computed locally, in seconds.

What's next

The graph is the foundation for everything that follows: conversion with typed dependencies as context, project-wide verification, and an agent that can cite call sites instead of guessing. We wrote about that loop in How CodeMorph verifies every migrated file.

Want to see your own project's graph? Download the beta, open a folder and press Ctrl Shift M.

Keep reading

Try CodeMorph now.

Download the beta, open a project and see your migration plan in under a minute.