We’re working on bringing Naji — the horse behind the name — home to Jacksonville.Bringing Naji home to Jacksonville.Meet Naji

BlogengineeringChris Lane Jones

We spent a month taking the file apart. It got 556 lines longer.

Code entropy, measured, and the ratchet that stops it — extraction was working and the file still grew, because features kept arriving. That part is inevitable: entropy is whack-a-mole and you do not win it. What helped was a number allowed to move in one direction.

Every figure below is read out of a file that is in the tree — the caps and their history from eslint.config.mjs, the ratchet that was retired and the reason from scripts/guardrails.sh. Nothing here is estimated, and nothing is rounded to make a better point.

On 27 July, AppShell.tsx was 3,250 lines. On 27 August it was 3,806. In between, it was being taken apart on purpose: handlers moved into session hooks, flags moved into stores, whole domains left the file. The extraction was real and it was working. The file still grew by 556 lines.

That is what code entropy actually looks like. Not a bad commit — there wasn't one. Not neglect — the file was being worked on constantly. Accretion simply outpaced extraction for a month, and nothing in the build said so out loud.

FIG 1The five files past 900 lines, with the 31 primitives in components/ui/ for scale — none of those has ever crossed it. The tall one moves: it grows to July's 3,250, keeps growing to 3,806 through the month it was being dismantled, and comes down to today's 3,564 only once the cap became an error.

A large file is a gravity well

Once one component owns the upload flow, the gallery, the canvas, the tools, history, export, dialogs and zoom, the cheapest place to put the next thing is that component. It already has the state. It already has the handlers. Every individual decision to add one more thing there is correct on the day, and the sum of those correct decisions is a file nobody can hold in their head.

The same shape shows up in the interface, at pixel scale. A primitive exists in components/ui/. Nine surfaces need it. Some import it; some paste the markup, because pasting was faster that afternoon. Nothing then changes on purpose — one copy inherits a size from its neighbour, another picks up a different one to match a rail, a third swaps the shared tooltip for a title attribute. Months later the sizes disagree by six pixels in three places and no one commit did it.

FIG 2Schematic, not a census: the surfaces are real, the count is the shape of the problem. The part that matters is the last beat — a fix lands in the primitive and reaches the three call sites that imported it. The six that pasted are exactly the places the next fix will not reach either.

The fix had to be a number, not a rule

"Keep files small" is not enforceable. It has no threshold, so it is never violated, and a review that mentions it is a matter of taste. What worked was narrower and duller: on 27 August every file already past 900 lines was pinned at its exact size that day — not a round number, its measured size — and the rule became that the number may only go down.

The important half is the second sentence in the config: when an extraction lands, that file's number drops to its new size in the same commit. The cap follows the file down and never drifts back up. Raising one is not a fix; it is the ratchet being unbolted.

We made them warnings first. That was the mistake. A warning is a number in a list nobody reads, and for a month three of the five files sat above their caps with a green build. They became errors on 26 September, and the three files went back under in the same change.

FIG 3The five, from the sizes pinned on 27 August to today's. Four came down. One went up — and is drawn going up.

The one that went up

The async contract test is 1,015 → 1,027. A branch that had lowered caps met a master that had added a feature and two interface passes to the same files, so the merged file was larger than either side intended. The number went up by twelve.

It is written into the config as merge arithmetic, with the reasoning next to it, because the failure mode of a ratchet is not a cap that moves — it is a cap that moves quietly. A raise nobody can see is indistinguishable from the rule not existing. The test for whether a ratchet is working is not "has the number ever gone up", it is "when it went up, did anyone have to say why".

And the one we took out

There was a second ratchet, on src/lib.rs, the engine's Rust entry point. It ran the same way and it worked the same way: 5,213 lines down to 4,771 over August and September. On 25 September it was removed. The reason is in guardrails.sh in the repo's own words — lib.rs is refactored often enough that a blocking line count cost more than it caught.

That is the honest boundary of the idea, and it is worth more than the success story. A ratchet earns its place where a file only ever accretes, because there the count and the problem are the same thing. Where a file is genuinely being worked — split, rejoined, moved through — the count stops tracking the problem and starts being a toll on the work. Both of those were true in this repo within a month of each other, and the difference was not the rule. It was the file.

What leaves a god file, and what stays

Two things made the AppShell work a real extraction rather than a relocation. First, every store setter accepts what React's useState setter accepts — a value or an updater — so the roughly thirty call sites moved without being rewritten. A migration that has to rewrite every caller is a migration that gets half-finished.

Second, the stores draw a hard line about what survives a reload. Which panel you left open is worth remembering; whether a dialog was showing when the tab closed is not, and restoring it would open a dialog over a photo you had not opened yet. That is a decision per field, written down once, rather than a default nobody chose.

What is left in AppShell after all of it is composition: the tree, and the wiring between pieces that now live elsewhere. The next reductions are not more handler extractions — they are structural, and they change what renders. The cap says so either way.

The part that would be easy to leave out

The ratchet stopped the growth. It did not undo it. AppShell is 3,564 lines today — 242 below its peak, and still 314 above where it was the day we started taking it apart.

That is not the ratchet failing. Features kept arriving the whole time, and they have to land somewhere; a file that is the composition root of the app will take new lines for as long as the app grows. Entropy here is whack-a-mole, and the honest version is that you do not win it. What the number bought was not a smaller file. It was the end of finding out a month late.

See it yourself

Every number in this post is one command away. eslint.config.mjs holds the five caps and the reasoning beside each; guardrails.sh holds the counts that are only allowed to fall, and the note explaining the one that was retired. If a figure here disagrees with the file, the file is right.