Code structure and size rules
Status: binding for new code from 2026-10-17. Enforced by pnpm size:check (part of pnpm check
and CI), Biome complexity warnings and the clippy lints in Cargo.toml. Data behind the numbers:
an internal code-size review (2026-10).
Cloudhal is written by humans and coding agents together. Both lose the thread in a 3 000-line file: an agent reads it in slices and edits the wrong copy of a helper, a reviewer cannot see what a diff touches, two parallel sessions conflict on the same file every day. Small modules with one job are the cheapest way to keep parallel work safe. These rules say how small, and how we get there without a big-bang refactor.
1. What mature projects use
| Source | File | Function | Params | Nesting | Complexity |
|---|---|---|---|---|---|
ESLint core defaults (max-lines, max-lines-per-function, max-params, max-depth, complexity) |
300 | 50 | 3 | 4 | 20 (cyclomatic) |
Biome 2.5 defaults (noExcessiveLinesPerFile, noExcessiveLinesPerFunction, useMaxParams, noExcessiveCognitiveComplexity) |
300 | 50 | 4 | — | 15 (cognitive) |
| Airbnb JS style | no file/function cap; max-len 100 |
— | — | — | off |
| Google TypeScript style | no numeric caps; “one concept per file”, named exports, no default exports | — | — | — | — |
Clippy (too_many_lines pedantic, too_many_arguments, cognitive_complexity, excessive_nesting) |
— | 100 | 7 | off by default | 25 (cognitive) |
| SonarSource cognitive complexity | — | — | 7 | — | 15 |
| McCabe (1976) | — | — | — | — | 10 |
Two lessons: tools ship tight defaults (300/50) that real codebases switch off because they are noisy, and style guides that do not cap size (Google, Airbnb) rely on review instead. A rule only helps if it is loud enough to fire on a real problem and quiet enough that nobody disables it. So Cloudhal sets soft limits near the tool defaults (review guidance, editor warnings) and hard limits about twice as high (CI fails), and freezes today’s oversized code in a baseline that may only shrink.
2. The numbers
Physical lines, comments and blanks included (what you scroll through).
| Measure | Soft (warn, review) | Hard (CI fails) | Enforced by |
|---|---|---|---|
.ts/.tsx/.js file |
400 | 800 | size:check; Biome noExcessiveLinesPerFile warns at 800 |
.astro component |
250 | 500 | size:check |
.css file |
400 | 800 | size:check |
.rs file (inline #[cfg(test)] modules) |
500 | 1000 | size:check |
Test file (*.test.ts, test/, testing*.ts, fixtures.ts) |
800 | 1500 | size:check |
| Function / method / component body | 80 | 150 | size:check; Biome noExcessiveLinesPerFunction 150; clippy too_many_lines 150 |
Nesting depth (if/for/while/switch/try; else if does not nest) |
4 | 5 | size:check; clippy excessive_nesting 5 |
| Positional parameters | 3 | 4 | size:check; Biome useMaxParams 4; clippy too_many_arguments 5 |
| Cognitive complexity (Biome/Sonar) | 15 | 25 (warning) | Biome noExcessiveCognitiveComplexity 25; clippy cognitive_complexity 25 |
| One string literal / template (client code, SQL, help text) | 6 000 chars | 12 000 chars | size:check |
Translation dictionary (apps/studio/src/i18n/<locale>.ts, i18n/lazy/*; one per registered language) |
— | no line limit | Studio bundle budget (§8) |
Why these values:
- 400 / 800 lines. Today 125 of ~670 TypeScript source files are over 400 and 41 over 800; the median module is ~150. 400 lines is roughly what one person (or one agent read) holds at once; at 800 a file nearly always mixes two jobs (§4). Higher for Rust because its tests live in the same file.
- 80 / 150 lines per function. ESLint’s 50 fires on every React component with a JSX tree; 80 is a component with its hooks and markup on two screens. 143 functions are over 150 today; none of them is a single job.
- 3 / 4 parameters. From four on, call sites become
fn(a, b, undefined, true). Beyond three, take one options object ({ siteId, rev, by }) — named, order-free and extensible without a breaking change. Hard limit 4 so that a fourth positional is a reviewed exception. - Depth 4 / 5. Deeper code is easier to read as an early return, a helper or a lookup table. Only 18 functions reach depth 5 today, 2 reach 6.
- Complexity is only a warning: cognitive complexity flags real tangles (
primitiveHtmlhas a cyclomatic complexity of 171) but also long, flatswitchdispatchers that are fine to read. The fix for both is in §5.
Functions inside test files are exempt from the function limits: describe/it bodies are lists
of cases. Test files still have a file cap; split suites by behaviour (store.publish.test.ts,
store.rollback.test.ts), as testing-*.ts already does.
Generated and vendored code is never measured: packages/core-contracts/src/generated,
crates/cloudhal-schema/src/generated, packages/core-ts/src/generated,
packages/ui/src/blocks/kumo, dist, node_modules, *.d.ts, tool configs.
3. The ratchet
scripts/size-baseline.json lists every file, function and string that was over a hard limit on
2026-10-17 (40 files, 201 functions, 1 string), with its size then.
- A new file, function or string over a hard limit fails
pnpm size:check. - A baselined one may not grow. Changing it is fine; adding lines means moving at least as many lines out (to a new module) in the same change.
- When a refactor shrinks a baselined entry, run
node scripts/size-check.ts --update. It only ever lowers numbers or drops entries, so it is always safe;size:checkreminds you when the baseline is looser than the code. - A genuine exception (rare: a generated-looking table, a vendored script) is
node scripts/size-check.ts --update --allow-growth; the baseline diff makes it visible in review, and the PR says why.
Function baseline keys are file::Outer>inner names (anonymous callbacks are named after their
call, e.g. createApi>app.get("/sites")(cb)). Renaming a baselined function makes it “new”; split
it instead.
pnpm size:report prints the full tables (per package, functions, strings, switches, churn risk);
the analysis report is its output plus the split plans.
4. One responsibility per module
A module does one of these, and its name says which:
| Kind | Contains | Imports allowed | Examples |
|---|---|---|---|
| Model / pure logic | types, validation, transforms, decisions; no IO, no clock, no fetch |
other pure modules, contracts | surfaces/checks/*, schedules/recurrence, design lint |
| Store / IO adapter | SQL, blob, HTTP, Browser Rendering, fetch; translates rows ↔ model |
model, @cloudhal/storage |
sql/store.ts, render-chrome.ts |
| Service / orchestration | one use case composed from model + IO (publish, build turn, deploy) | model, IO | build-turn.ts, commands/deploy/* |
| Transport | Hono routes, MCP tool tables, CLI commands: parse input, call a service, map errors | service, model | routes/*, tools/* |
| Rendering | HTML/Astro/React output from a model; no data fetching | model | render/primitives/*, Studio sections |
| Runtime client code | browser code shipped as a string (§7) | none at runtime | *-client.ts |
Signals that a file needs splitting: it has two of the rows above; its tests need two unrelated setups; two sessions edit it for unrelated reasons in the same week (see churn in the report).
Folders and stable public paths
When a file grows past the soft limit, move its parts into a folder with the same name and keep
the original file as the barrel. Importers, package.json exports (source and publishConfig)
and tests do not change:
packages/core-ts/src/surfaces.ts // barrel: the old public API, named re-exports onlypackages/core-ts/src/surfaces/ shared.ts // limits, tiny guards catalog/ // primitive definitions, createCatalog bindings/ // values, the function table, resolve checks/ // checkSurface orchestrator + rule tables patch/ // JSON Patch, patchPage prompt.ts // catalogPrompt- Barrels only re-export, by name (
export { checkSurface } from "./surfaces/checks/index.ts";); no logic, no side effects, so internal helpers do not leak into the public API. Inside a package import the concrete module, not the barrel, to avoid cycles. - A new area that starts as a folder uses
index.tsas its barrel. - Prefer explicit named re-exports over
export *in package entry points, so the public API is a list someone can read (@cloudhal/ui’sexport * from "@cloudflare/kumo"is the documented exception). - Worker-safety (AGENTS.md rule 5) is per module: a split must not pull a Node-only import into a
barrel that a Worker reaches. Put Node-only parts behind their own subpath (
./node).
Per-package conventions:
packages/core-ts: one folder per domain area (surfaces/,taxonomy/,menu/,assets/), withmodel.ts,checks.ts,sql.ts/store.ts,index.ts.store.ts(SiteGraphStore) is a facade: method bodies live in per-area engines (store/entries.ts,store/snapshots.ts,store/publish.ts), like the existingAssetEngine/TaxonomyEngine/MenuEngine, sharing oneStoreContext. The facade itself is a documented exception to the soft limit while its public method list stays flat.packages/api:routes/<area>/index.tsbuilds a Hono sub-app; handlers live next to it (routes/<area>/<verb>.tsonce the area passes 400 lines);app.route("/<area>", …)mounts it.packages/mcp-server: onetools/<area>.tsper tool family, each exporting a tool table; the server registers tables, it does not define tools.packages/cli:commands/<name>/per command with a{ name, help, run }table incli.ts.packages/astro-renderer:surfaces/render/<primitive>.tsrenderers keyed in a table.apps/studio: see §8.starters/blog:src/styles/features/<feature>.cssper feature, imported byglobal.css(§8).
5. Dispatch tables, not giant switches
A switch that maps a name to a behaviour (primitive → HTML, command → handler, function name →
implementation, A2UI kind → surface node) becomes a typed table:
type PrimitiveRenderer<K extends PrimitiveName> = (node: PrimitiveNode<K>, ctx: RenderContext) => string;
export const PRIMITIVE_RENDERERS: { [K in PrimitiveName]: PrimitiveRenderer<K> } = { Text: renderText, Image: renderImage, // …};
export const primitiveHtml = (node: PrimitiveNode, ctx: RenderContext) => (PRIMITIVE_RENDERERS[node.kind] as PrimitiveRenderer<typeof node.kind>)(node, ctx);- The mapped type makes a missing entry a type error (the exhaustiveness a
switch+assertNevergave). - Each renderer is a small function in its own module, testable alone; adding a primitive touches one new file and one table line.
- Keep a
switchfor a handful of cases that share local state. Rule of thumb: more than ~10 cases, or cases longer than ~10 lines each, is a table.
6. Naming, exports, comments, errors
Match what is already in the repo:
- Files kebab-case (
build-turn.ts,surface-interop.ts); React components PascalCase (PageEditorPage.tsx); tests next to the code (foo.test.ts) or intest/. - Exports named only; default exports only where a framework requires them (Astro/Vite
configs, Workers’
export default { fetch }). Export what other modules use, nothing for tests only — test through the public function or move the helper to its own module. - Comments as the repo writes them: a module doc comment saying what the module is for, which
RFC/ADR it implements and any non-obvious constraint (Worker-safety, versioned assets); short
/** … */on exported API whose name is not enough; inline comments only for why. No commented-out code, no banners. - Errors: throw typed errors from
@cloudhal/core-ts(ConflictError,NotFoundError, …) in model/service code; map them to HTTP/MCP results only in the transport layer (packages/apierror mapping, MCPinvariantHint). Never swallow an error without a comment saying why; nevercatch {}around IO except to translate it. - Parameters: more than three → one options object (§2).
7. Runtime client modules (String.raw code)
packages/astro-renderer/src/*-client.ts ship small browser modules as string constants (served
from /_cloudhal/, script-src 'self', no eval). They stay that way, with limits:
- One module per behaviour (
motion,carousel,overlay,surface…), one exported constant, versioned file name, a test pinning the hash (existing practice). - Size cap: 12 000 characters per string (hard), 6 000 soft.
surface-client.tsis at ~11 000 and is the first to split (bindings / actions / toasts as separate modules loaded on demand). - Shared helpers (pointer parsing, toast) live in one
client-shared.tsconstant concatenated by the module that needs it, not copied. - A string over ~30 lines that is checked code (e.g.
RENDER_CHECK_SCRIPTinpackages/mcp-server/src/render.ts, 26 000 chars) should become a real.jsmodule undersrc/client/read at build time or bundled as text, so Biome and the type checker see it. - SQL in migrations is data and is exempt in spirit; keep each migration to one feature.
8. Studio screens and CSS
Studio (apps/studio):
- A route file (
src/routes/*Page.tsx) is composition only:PageLayout, itsSectionCards and the data hooks. Soft 250, hard 800 lines (the file limit); the component function ≤ 150. - Move each section into
src/components/<page>/<Section>.tsxand data/mutation logic intosrc/components/<page>/use<Thing>.tshooks (not underroutes/: design-lint treats every.tsxthere as a route that needs a template). The route file keeps what its template requires (PageLayout,crumbs=,title=, andEditorLayout/SectionCardfor editors). Reusable visual patterns become apackages/uiblock (AGENTS.md rule 7). - Register a split screen’s folder in
SCREEN_PARTS(apps/studio/test/design-rules.test.ts): the design-rules tests then read the route and its folder as one screen (first done forroutes/PageEditorPage.tsx→components/page-editor/). - Modules imported only by a lazy route stay in its chunk; never import a heavy module from the
shell (
apps/studio/bundle-budget.tsLAZY_ONLY). src/lib/api.tsis split per resource (lib/api/<area>.ts);lib/api.tsstays as the barrel (137 files import it by that path).- i18n dictionaries are data, not code, so they have no line limit (a long list of strings is
not hard to read). What they cost is weight and merge conflicts, and the rules aim at those:
- only the active language loads:
en.tsis in the first load (the fallback), every other<locale>.tsarrives throughloadLocale(LAZY_ONLYkeeps it out of the first load);apps/studio/bundle-budget.ts(initialGzipBytes) is the guard, notsize:check; - a new area’s strings go in a lazy namespace (
src/i18n/lazy/<ns>.<locale>.ts, DESIGN.md §1), loaded with its route;en.ts/tr.tskeep what the shell needs on first paint; - moving route-specific groups out of the eager files (and loading only the active language) is a bundle improvement, done for the user’s sake, not to meet a line count.
- only the active language loads:
CSS (starters/blog, themes): one file per feature (styles/features/prose.css,
styles/features/surfaces.css, styles/features/comments.css …), imported in cascade order by
global.css, which keeps only the Tailwind import (its source("..") is relative to that file),
fonts.css and the feature imports. CSS needs every @import before other rules, so tokens and
@theme live in features/tokens.css. The features stay unlayered (layer(...) would let
utilities beat component classes) and in order: later files override earlier ones at equal
specificity. Same 400/800 limits.
9. Workflow
- Before writing: if the file you are about to change is over the soft limit, put the new code in a new module next to it.
pnpm checkruns Biome (errors),size:check, typecheck and tests.pnpm lintalso shows the complexity warnings; your editor shows them inline.- Refactors of baselined files follow the split plans in the analysis report, one file per PR,
public import paths unchanged, behaviour unchanged (tests untouched or only moved), then
node scripts/size-check.ts --update.