Split one giant app.js / app.ts / page / app.py into clean,
load-ordered modules using pure static analysis — no bundler, no LLM, no
config. SplitCode parses your code, finds real dependencies between
top-level statements, groups coupled code together, and emits a drop-in
bootstrap loader, so existing entry points keep working unchanged.
Keywords: javascript splitter, split js file, typescript splitter, split python file, refactor monolithic script, js code splitting without bundler, legacy code modularization, script dependency ordering.
- 📦 Break up monolithic scripts — turn a giant
app.jsinto focused, reviewable files grouped by what actually depends on what. - 🔗 Correct load order, computed — references that run immediately enforce ordering; deferred callbacks don't (so a click handler won't manufacture false load-order cycles).
- 🚀 Zero-integration loader — the generated
app.jsbootstrap pulls in every split file in order. Deploy the folder; change nothing else. - 🔍 Honest output —
manifest.jsonreports hubs, duplicates, cycles, and parser mode, so you see exactly how cleanly your file decomposed.
npm release pending —
npx/-gcommands below work oncesplitcodeis published. Until then, use the from-source form (identical behavior).
# once published (no install needed):
npx splitcode app.js ./split-out
# or install globally:
npm install -g splitcode
splitcode app.js ./split-outFrom source today (same thing — splitcode is just the bin alias for
split-js.js):
npm install # acorn + node-html-parser (+ optional typescript@5 for .ts)
node split-js.js app.js ./split-outDeploy ./split-out and keep loading app.js from your pages — the
generated loader pulls in the rest in dependency order. That's the whole
migration.
Paste this into your project's CLAUDE.md / AGENTS.md so agents split
before they read:
For files over ~100KB: run
npx splitcode <file> ./split-outfirst, plan from./split-out/manifest.json, edit per-file, never reorderorder, test after every change.
A ready-made Claude Skill lives at skills/splitcode/SKILL.md — copy it
into your project's .claude/skills/ (or global skills dir) and agents
will invoke it automatically when files get large. Agent-readable docs:
docs/llms.txt (also served at /llms.txt on the site).
- Parse the file with
acorninto an AST (classic scripts; ES modules via fallback). - Collect declarations —
function/class/var/let/const,importbindings, plus global writes (foo = …,window.foo = …,Object.assign(window, {…})). - Walk each statement with a scope-aware free-variable collector that
distinguishes immediate references (run the instant the statement
runs) from deferred ones (callbacks that fire later). Immediate
includes IIFEs,
fn.call/fn.apply,Promiseexecutors, and a spec-backed allowlist (map/forEach/filter/every/some/find*/reduce*/flatMap/sort,str.replace(re, fn),Array.from(it, fn),JSON.stringify(v, fn)). Block scoping (if/for/switch/bare{}) is respected;varstill hoists;withbodies count every ref as live;window.fooreads link back towindow.foo = …writes. - Cluster tightly-coupled statements: connected-components first, then Louvain modularity refinement to cut weak bridges in oversized clusters. Over-shared "hub" globals (used everywhere) are excluded from grouping so they don't weld the file into one blob.
- Topologically order the files on immediate dependencies only.
Function declarations are hoisted, so they move freely; anything that
executes immediately (a bare call,
const x = f()) keeps its relative position. Grouping ignores edge direction, so a cluster assignment can theoretically demand an impossible order (A before B before A) — the tool condenses such circular groups (Tarjan SCC) into single files first, so the emitted order is always satisfiable. Merges are logged and recorded (sccMerged); files get bigger, never wrong. - Write the outputs (see below): cluster files,
manifest.json,script-tags.html, and theapp.jsbootstrap loader.
splitcode <input.(js|ts|html|py)> <outDir> [--hub-ratio 0.12] [--min-chars 400] [--loader <name> | --no-loader] [--lang js|ts|html|py]
# from source: node split-js.js <same args>
| Option | Default | Meaning |
|---|---|---|
--hub-ratio |
0.12 |
Names referenced by more than this fraction of statements are treated as shared app state, not a clustering signal. Floor: names used >6 times are always hubs; --no-hubs disables. |
--min-chars |
400 |
Clusters smaller than this merge into their neighbour, avoiding a pile of one-line files. Strictly validated (non-negative integer). |
--loader app.js |
on | Writes a bootstrap loader named app.js into outDir. Keep loading just that ONE file — it pulls in the split files in order. Load it with a plain <script src>, not async/defer. Rename with --loader bootstrap.js; a cluster that would collide gets suffixed (app-2.js). |
--no-loader |
— | Disables the loader; paste script-tags.html into your page instead (JS/TS/HTML; Python has no tags file). |
--loader-mode classic|inline |
classic |
classic: loader pulls in parts at runtime via document.write (ESM inputs get type="module" tags). inline: loader is self-contained — all parts concatenated in order, no runtime injection, no extra requests. |
--no-louvain |
— | Skip Louvain refinement; group by connected components only (faster, more predictable). |
--no-hubs |
— | Disable hub suppression entirely (--hub-ratio 0 can NOT do this — the >6 floor makes 0 the most aggressive setting). |
--lang js|ts|html|py |
auto (extension) | Force the frontend for extension-less or oddly-named inputs. |
--check |
— | Preflight only: scan for risk patterns, print warnings, write nothing. Exit 0 = clean, 2 = risky. outDir not needed. |
--strict |
— | Refuse to write when preflight warns (exit 2 instead of writing risky output). |
--force |
— | Allow overwriting colliding files and outputting into the input's own directory. The input file itself is still never deleted. |
--dry-run |
— | Plan everything, write nothing. outDir optional. |
--max-bytes |
33554432 |
Refuse inputs larger than this many bytes instead of risking OOM (0 = unlimited). |
--timing |
— | Print per-phase milliseconds at the end. |
--quiet |
— | Suppress info logs (warnings, errors and the final Wrote line still print). |
--help / --version |
— | Print help / version, exit 0. |
Exit codes: 0 = success (warnings may be present unless --strict),
1 = error, 2 = risky (warnings under --check or --strict).
Flags may appear before or after outDir. The output can replace the
original file: keep loading just the loader and the host app behaves
identically (verified syntax-only — smoke-test split apps before shipping).
Every run scans for constructs the splitter handles poorly and warns
before writing anything. --check runs only the scan:
| Lang | ⚠ Warns | • Notes |
|---|---|---|
| JS | eval, indirect X.eval, new Function, setTimeout("…") strings, dynamic import(), importScripts (bare + X. member form), X.prototype.y =, Object.assign(X.prototype, …), dynamic global keys (window[x] =, non-literal Object.assign(window, …)), Object.defineProperty, unparseable file |
getters/setters, top-level await, require(…), bare writes to undeclared names (e.g. loop inits) |
| TS | eval, indirect X.eval, new Function, setTimeout("…") strings, dynamic import(), importScripts (bare + member form), X.prototype.y =, Object.assign(X.prototype, …), Object.defineProperty (via compiler API) |
getters/setters, top-level await, require(…) |
| HTML | <script> inside <template> (would be ACTIVATED), |
type="module" left in place |
| Python | eval/exec strings, __import__, relative imports (BREAK in parts), from __future__ (must stay first) |
import *, __file__ (points at bootstrap) |
Dispatch is by file extension (override with --lang js|ts|html|py).
One shared backend clusters, orders and names — each language gets a
frontend plus its own loader.
| Input | Frontend | Output | Loader |
|---|---|---|---|
.js / .mjs / .cjs |
acorn AST, full scope analysis |
.js parts |
app.js — synchronous document.write bootstrap, keep loading just it |
.ts / .tsx |
typescript@5 API (v6+/native port has no JS AST API — pinned ^5.9) |
.ts parts (types kept) |
same mechanism, .ts file |
.html |
pools every inline classic <script>; external src untouched |
rewritten page + .js parts |
loader tag inserted at the first inline block's position |
.py |
python3 stdlib ast (requires python3 on PATH) |
.py parts |
app.py bootstrap — execs parts in order in shared globals, so module names behave exactly as one file |
TypeScript specifics: type-position refs (: Foo, implements Bar) cluster
but never order (erased at runtime); decorators/enum initializers/namespace
bodies/static blocks are immediate; field initializers are deferred
(construction time); <Foo /> tags count as refs.
HTML specifics: type="module" / non-JS blocks (ld+json) and unparseable
blocks are left in place (warned); an external src script between inline
blocks can't be ordered against the pool (warned as externalInterleave).
Python specifics: def-time evaluations (decorators, defaults,
annotations, bases) are immediate; class bodies run at creation; methods
can't see class-scope names (real Python scoping). __name__ == "__main__" blocks run exactly as before; caveat: __file__ inside a part
points at the bootstrap. Behavioral check: original vs split stdout
diffed — identical (modulo independent-print interleaving, see limitations).
-
Cluster files — named after their most-used declaration in kebab-case (
auth-token.js),section-N.jsfallback. Each carries a// Declares: …header comment. -
Loader (
app.js) — resolves its own directory and injects the split files synchronously viadocument.writeduring parsing (the only single-file mechanism with<script>-tag semantics). Caveat: Chrome may blockdocument.write-injected scripts on very slow (2G) connections — usescript-tags.htmlthen. -
manifest.json— machine-readable result:Field Meaning orderFiles with declares+statementCount, in load orderloaderEntry-point file name ( nullwith--no-loader)loaderModeclassic(runtime injection) orinline(self-contained)strictWhether --strictwas on for this runtool/toolVersion/schemaVersionProducer identity ( splitcode, semver, manifest schema1)input{file, bytes, statements, sha256}of the split sourceoutput{statements, bytes}across parts (statements must equal input)hubNamesSuppressedShared-everywhere globals excluded from grouping hubThresholdEffective use-count above which a name is a hub cycleFallbackSafety net only (condensation makes it unreachable); whether original-order fallback was used sccMergedCircular-dependency groups merged into single files to guarantee order parserModescript, ormoduleif the ESM fallback parsed itduplicateDeclarationsRepeated top-level names (callers link to every same-name declaration) verifiedAlways "syntax-only"— what was (and wasn't) proven -
script-tags.html— paste-in alternative to the loader.
- Stale cleanup — reruns delete previous tool output (files listed in
the prior
manifest.json, plus the exact names about to be written — never extension globs) fromoutDirfirst, so orphaned files from a different cluster count can't linger. Unchanged files are hash-skipped, not rewritten. Foreign files are never touched; collisions refuse unless--force. The input file itself is never deleted or overwritten (same-dir output with a colliding loader name refuses — use a separate outDir). - CLI validation — bad
--hub-ratio/--min-chars/--max-bytesvalues and unknown flags exit with an error instead of silently becomingNaN. Safety refusals (input overwrite, foreign-file collision) exit1;--strict/--checkreport risk with exit2. - Duplicate declarations — legal
var/functionredeclarations warn on console and in the manifest; callers link to every same-name declaration so load order stays safe (files get more coupled, never misordered).
- 671 statements in → 671 out. Nothing lost or duplicated (input checksum verified unchanged after the run).
- Reassembled output passes
node --check(syntax only — smoke-test split pages for behavior). - 18 files, largest 95 statements; no load-order cycles; zero ordering
violations; 1 hub suppressed (
toast); 1 duplicate declaration reported (showTabEditor— callers link to every same-name declaration).
- Side-effect order across files. Order is enforced only along
dependency edges. Two top-level statements with side effects (prints, DOM
writes) but no shared names may run in a different relative order after
splitting — demonstrated by test: independent
printlines swapped files. If exact interleaving matters, keep those statements coupled (shared name) or in one file. - An unknown receiver's callback defaults to deferred (
arr.map(fn)is covered; a customrunNow(fn)is not) — the general case is undecidable by syntax analysis. Keep synchronously-coupled code together or verify order. - Dynamic global keys (
window[x] = …) can't be tracked statically — detected and warned asdynamic-global-key, but readers stay unlinked. - Heavy shared mutable state genuinely merges files — the tool can't invent
boundaries that don't exist. Check
hubNamesSuppressedand cluster sizes. manifest.jsoncarries"verified": "syntax-only"as a reminder of what was (and wasn't) proven.
- Node.js ≥ 16.
- JS/HTML:
acorn+node-html-parser(npm install). - TS:
typescript@5(optional dependency — installed by default, skippable withnpm install --omit=optional; missing install fails with guidance). - Python:
python3on PATH (stdlibastonly — no pip packages).
MIT — do what you want, no warranty. Static analysis can miss dynamic edges; smoke-test split pages before shipping.
unn-Known1 — ptelgm.yt@gmail.com (github.com/unn-Known1)