Guideline for deciding how to split a file — or whether to split it at all.
This is a style note, not a hard rule. Apply judgment. When in doubt, prefer fewer, more cohesive files over many small ones.
This file is also intended to instruct an agent when a file is about to be split up.
When a file grows large, the first instinct is often “one class per file.” This is a syntactic decomposition. The TypeRoof preference is a coupling‑based decomposition: group files by what changes together, not by how many top‑level declarations they contain.
A module boundary should hide a decision that is likely to change. If two classes, one class and its helpers, or a base class and its only subclass always change in lockstep, they belong in the same file regardless of size.
Keep together
typeSpecGetDefaults / nodeSpecGetDefaults, TYPESPEC_PPS_MAP / NODESPEC_PPS_MAP): seeing them side‑by‑side makes the parallel visible and inconsistencies obvious.TYPE_SPEC_PROPERTIES_GENERATORS with the *Gen functions it contains).Split apart
shared or utils file rather than duplicating or picking a random home.For a layout folder (e.g. lib/js/components/layouts/<name>/), a useful shape is:
index.typeroof.jsx — Model, Controller, public exports.TypeSpecRampController.zones (or equivalent) already enumerates.shared file for UI helpers used by multiple zones.A 4000+ line single file is a signal to split. A folder with 30+ files in flat layout is a signal to consolidate.
Filenames describe subsystems, not single symbols. A file named foo.mjs because it exports foo is a symptom: it hides what else lives in the file and, in practice, ends up collecting unrelated symbols over time. Name files after the responsibility, not after one member of it.
Import lists are short. A file that imports 20 siblings is almost always mis‑grouped: either it is a controller (in which case this is expected and limited to index.typeroof.jsx) or its contents should be folded together with some of its dependencies. Watch the sibling‑import count as a proxy.
Tier direction is one‑way. UI files import engine files; engine files do not import UI files. If this ever needs to reverse, it is a design smell, not a naming problem.
Circular dependencies are absent. Coupling-based decomposition risks “spaghetti” logic if boundaries aren’t clean. If File A and File B are so tightly coupled that they must import each other to function, they are not two modules; they are one module that has been artificially split. The Fix: Either merge them back into a single file or extract the shared logic into a third “leaf” module (like a types.mjs or constants.mjs) that both can import without knowing about each other.
Before splitting, write a short plan — one paragraph per target file naming its intended responsibility and which existing symbols go into it. Check the plan against the actual coupling (who imports whom) rather than the syntactic class list.
For files over ~1000 lines that will be split on a feature branch, propose the target decomposition in a short planning document before executing it — not a heavyweight RFC, just “here are the N pieces I see and why.” This is cheaper than discovering the grouping is wrong after 39 files land.
TypeRoof uses a bundler with tree‑shaking. Consolidating files does not pull unused code into the bundle: unused exports are still dropped. Optimize the source layout for reading, not for per‑file bundle boundaries.
Two distinct concerns get conflated here. Treat them separately.
.typeroof.jsx. In this project the extension is the opt-in: the bundler’s JSX transform is wired to .typeroof.jsx specifically, and .mjs is treated as plain ES modules..mjs..typeroof.jsx extension (the JSX transform is a no-op when no JSX appears), but it muddles the UI/engine distinction at a glance. Prefer .mjs for engine files.Rule: a split that moves JSX out of a file should rename to .mjs; a consolidation that brings JSX into a file should rename to .typeroof.jsx.
Project-wide, .prettierignore uses a deny-by-default pattern (*) and allow-lists specific globs. Current reality:
**/*.jsx is globally allow-listed → every .typeroof.jsx file is prettier-formatted, wherever it lives.**/*.mjs is not globally allow-listed → .mjs files are skipped by prettier unless a directory-specific allow-list covers them.**/ramp/*.* is a blanket allow-list → inside lib/js/components/layouts/ramp/, prettier formats all extensions, including .mjs.Consequences worth knowing:
.typeroof.jsx is the right extension on both grounds — JSX is available, and prettier covers the file..mjs is technically correct (no JSX needed). If prettier coverage matters for a given file, either (a) place it under a directory that’s allow-listed (e.g. ramp/), or (b) extend .prettierignore to cover it explicitly — do not rename .mjs → .typeroof.jsx purely to obtain prettier coverage, that conflates the two concerns.| Scenario | Extension | Rationale |
|---|---|---|
| New UI component | .typeroof.jsx |
Needs JSX; prettier-covered. |
| New engine module, anywhere | .mjs |
No JSX; extend .prettierignore separately if formatting is desired. |
| A previously JSX-free file starts producing DOM | rename .mjs → .typeroof.jsx |
Extension is the JSX opt-in. |
Extracting pure logic out of a .typeroof.jsx into its own file |
new file is .mjs |
No JSX in the extracted module. |
Mixed UI+engine .mjs file (historical) getting split |
UI part → .typeroof.jsx, engine part → .mjs |
Both concerns resolved. |
type-spec-*, node-spec-*, style-patch-* form consistently. Do not mix typespec- / type-spec- in the same folder.PascalCase for classes, camelCase for functions and values — this is already the de‑facto rule; noted here so the coupling note is self‑contained.docs/planning/ramp-layout-coupling-based-decomposition.md — worked example of applying this guideline to a 4700‑line file that had been mechanically split into 39.