Skip to main content

ADR 0008: Hold file size and module coupling with a two-directional ratchet

  • Status: โœ… Accepted
  • Date: 2026-08-19 (mechanism proven in UNFORCE 2026-08-16)
  • Deciders: @akshay
  • Tags: frontend, gpu-backend, tooling, code-health

Contextโ€‹

Seven code repos had accumulated files nobody could hold in their head: contact_detection.py at 1,606 lines, admin and auth-function files near 1,500, contentStudio.ts at 2,783. None of them were written that way. They arrived forty lines at a time, each addition individually reasonable, and no single commit was ever the wrong one to approve.

The sibling product UNFORCE adopted this in 2026-08-16 and reported the same shape of problem, including a dependency cycle in its frontend. AceSense adopted the mechanism after it had been proven there.

Constraints that shaped the answer:

  • The repos span three toolchains (vitest, pytest, flutter test). A check that only runs in one of them polices one seventh of the code.
  • CI must stay green on the day the check lands. A rule that turns every repo red immediately gets disabled, not obeyed.
  • Nothing may depend on a linter plugin or service that has to be installed before the rule can run.

Options Consideredโ€‹

Option A โ€” a lint rule with a fixed maximumโ€‹

  • Pros: familiar, one line of config.
  • Cons: every existing offender fails on day one. The realistic response is a suppression comment per file, which converts real debt into invisible debt.

Option B โ€” report only, no gateโ€‹

  • Pros: zero disruption.
  • Cons: nothing stops the next 3,000-line file. A report nobody has to act on is a report nobody reads.

Option C โ€” a ratchet locked at today's measurements (chosen)โ€‹

  • Pros: green on day one, because every number was measured rather than chosen. Debt is written down per file and can only shrink.
  • Cons: a committed budget file to keep current, and one more thing to re-lock after a refactor.

Decisionโ€‹

Each repo carries architecture.budget.json recording the measured size of every file currently over defaultMaxLoc (400), checked by a test inside that repo's normal suite โ€” no new CI job, no new dependency.

The check fails in both directions, and that is the point. A file over its budget is a regression. A file under its budget is also a failure until the smaller number is recorded, because an improvement that is not written down leaks back the next time someone needs somewhere to put forty lines.

StackRuns as part ofRe-lock with
TypeScript (5 repos)pnpm testpnpm arch:update
Python (gpu-backend)pytestpython tools/architecture.py --update
Dart (frontend)flutter testdart tool/architecture.dart --update

The frontend additionally records cross-feature imports. A feature importing another feature fails unless the edge is already listed, and an edge that disappears must be delisted, so decoupling is permanent.

Entries only ever leave these lists. A new file is held to 400 lines with no exception โ€” the list is existing debt, not somewhere to add to.

Consequencesโ€‹

Where AceSense stands. Locked at the sizes measured on adoption, so the check is green today and the debt is written down rather than hidden:

RepoFiles over 400 LOCLargest
acesense-frontend342,517
acesense-gpu-backend161,606
acesense-launchpad142,783
acesense-admin111,485
acesense-auth-function111,482
acesense-annotate11,151
acesense-landing1429

Unlike UNFORCE, AceSense's frontend budget records file sizes only โ€” the cross-feature import rule is not yet enforced here. That is the obvious next increment, and the reason to record it rather than quietly omit it.

It caught its own author. The gpu-backend split was rejected until the segmentation barrel's private re-exports were marked deliberate rather than deleted: segment.py reaches those helpers through the package so tests can monkeypatch them, and removing the re-export would have left the tests passing while testing nothing.

What it does not do. It says nothing about whether a split is good. Eight frontend files remain over the limit and each is a single class doing a single job โ€” three services and five State classes. Splitting those would move the same class behind a different filename. They stay long, and the budget holds them where they are.

Trap worth knowing. Splitting a module can silently disarm tests. In Python, a test that monkeypatches an internal stops reaching it once the function and its caller are in different modules โ€” the test passes while testing nothing. In Dart, two contract tests read screen source by path and were left reading a stub. Both classes of failure are silent. See gpu-backend architecture and the module docstrings.