SmolForgeField Notes

Notes on systems,
code, and craft.

All notes

Engineering

We Let Forge’s Tests Reach 62,000 Lines

The cleanup made tests faster by giving accumulated coverage a lifecycle.

Forge accumulated 389 test files without deciding when each proof should run. We reorganized the suite, deleted hypothetical history, and cut affected feedback by 82%.

That was the project owner’s correction when I first framed this as a story about faster affected tests. He was right. Forge did not wake up one morning with a bad test suite. We let hundreds of locally reasonable decisions accumulate without a repository-wide answer to one question: when does each proof need to run?

By the time we stopped to measure, Forge had 389 test files and 62,030 lines of test code. Ordinary API edits paid for integrations they could not affect. The root test command still omitted the web app, CLI, and agent-runtime packages. Expensive fixtures replayed the same schema through copied helpers. Pre-production migration tests defended a history no user depended on.

The cleanup did not pursue the smallest possible test count. It gave tests a lifecycle, removed proofs for product behavior we had deliberately abandoned, and made shared setup cheap and isolated. A representative API edit became 82% faster and a runner edit 92% faster, while the complete unit gate grew from 1,825 to 2,226 tests.

Timeline of Forge test growth across selected commits. The repository grows from 12 test files and 3,871 lines in May to 389 files and 62,030 lines before the test architecture work. Product work reaches 412 files and 63,902 lines before the future-only cleanup, and the final point has 408 files and 61,641 lines.
Selected commits from the repository history. File and line totals use the same checked-in inventory rules as the final measurement; points show the cumulative suite, not the contribution of one commit.

Every test was reasonable in isolation

Forge grew quickly. Git transport gained protocol and packfile tests. The CI runner added sandbox, cache, lease, and release coverage. Deploy added static hosting, provider publication, activation, rollback, retention, OpenNext, and three-project integration tests. Identity, Wiki, Gists, Slack, repository agents, and account controls each brought their own state and failure cases.

That is how the suite moved from 12 files in May to 33 by July 19, then 244 six days later. By July 31 it had 329 files. The test-architecture work began at 389 files; continuing product work took the branch to 412 before the future-only cleanup landed.

The problem was not that those features had tests. The problem was monotonic accumulation. New behavior acquired proof, but proof rarely acquired an execution tier, a fixture owner, or a retirement condition. The default path became the union of every concern we had ever considered important.

I also treated “more coverage” as a one-dimensional good. It is not. A test contributes confidence only at a boundary where its result can change a decision. Running a provider rollback integration after editing an unrelated JWT helper adds latency, not useful evidence. Omitting web and CLI tests from the release gate saves time, but it saves the wrong time.

The bloat had four different shapes

Calling all of this “too many tests” would have led to the wrong repair. The inventory exposed four separate problems:

  1. Scheduling bloat: package-wide suites ran after narrow edits even when static imports could identify related tests.
  2. Fixture bloat: five migration loaders and eight SQLite setups repeated expensive initialization and subtly different parsing or seed behavior.
  3. State bloat: tests shared wall clocks, provider topology, generated identifiers, and remote account state. Failures escaped their owning test.
  4. Historical bloat: 82 upgrade migrations, adoption checks, dual-authority cutover tests, and cleanup machinery preserved a pre-production past that never became a user contract.

Those categories required different decisions. Scheduling bloat should be routed. Fixture bloat should be consolidated. State bloat should be isolated. Historical bloat should be deleted—but only after identifying equal or stronger coverage for the behavior we still intended to ship.

We gave every proof a place to live

The new architecture has three explicit tiers.

test:affected answers the local question: what can this change plausibly break? It computes the merge-base diff, maps changed paths to package and contract owners, and asks Vitest for related tests when static dependency analysis is trustworthy. A dynamic import, computed require, deleted source file, package manifest, or ambiguous boundary selects the complete owning package. Shared contracts, lockfiles, migrations, and routing configuration widen the selection further. Ambiguity always makes the run larger.

test:contracts owns behavior that file dependencies cannot prove: fresh-schema validity, provider publication and activation, rollback and retention, exact-SHA private-source authorization, OpenNext hosting, the three-project AIE integration, and release binding contracts.

test:full answers the release question. It runs every meaningful workspace test and typecheck, the contract tier, builds, migration verification, local Worker integration, real Git smoke, and the dependency audit. main and deployment use this result, not the affected selection.

The distinction sounds obvious in retrospect. We had allowed one command to answer both questions, badly. It was too broad for iteration and still incomplete for release.

Deletion needed a coverage ledger

The most aggressive part of the cleanup was possible because Forge was not in production. There was no customer database to upgrade and no released client depending on the old fields or dual Deploy authorities. Preserving that compatibility would have made hypothetical history more authoritative than the product we wanted to build.

We replaced 82 upgrade migrations with one canonical fresh schema stored as five D1-size-bounded fragments. We removed 85 remote or generated-account tests that used shared state or asserted fixed responses, plus 17 cutover and adoption cases for the retired authority bridge. In total, 102 obsolete tests and 2,755 test lines left the suite.

This was not a line-count exercise. Every removal went into a ledger with its replacement boundary. Current authorization stayed in focused route and policy tests. Provider activation, rollback, retention, and exact-SHA behavior stayed in contract integrations. The fresh-schema verifier still constructs an empty database and fails on integrity, foreign-key, seed, required-object, or forbidden compatibility mismatches.

Focused unit tests also remained when an integration traversed the same feature. A large test can prove the end-to-end contract; it cannot always tell us which policy decision failed.

The simpler schema was slower at first

Deleting history exposed a useful embarrassment. The first canonical loader parsed 602 complete SQL statements—including trigger bodies—and sent them to D1 one call at a time. Fresh initialization took 3,998 milliseconds.

The schema was simpler, but the call topology was worse.

Packing those statements into ten batches below D1’s 24 KiB execution limit cut initialization to 102 milliseconds, a 97.4% reduction. Product tests that need only the current SQLite schema now construct one digest-keyed 254ms baseline per process and clone an isolated database in 3ms.

The rule is not “cache database tests.” It is: cache only the state whose correctness another mandatory boundary proves, key it by the full input digest, and give every test its own clone. Dedicated migration verification still starts empty and cannot use that shortcut.

Flakes were ownership bugs too

One admin-abuse test used a fixed expiry date that eventually moved into the past. The AIE hosting fixture mixed several wall-clock reads with a mutable fake provider topology, producing Fake provider production version changed even though the expected release sequence was deterministic.

We did not add retries. The admin test freezes an explicit instant. The hosting fixture injects one clock into database evidence and provider receipts, creates isolated Miniflare, D1, provider, and release state, awaits its background operations, and tears them down. Each repair completed 38 fresh-process runs without failure or retry.

Those results do not prove the whole suite is flake-free. They prove something more useful: the two known failures now have explicit owners instead of environmental luck.

Faster feedback was the consequence

On the same Apple development machine, with a warm npm dependency store and no cached test results:

Representative change Before After Change
API JWT helper 165 files / 12.38s 26 files / 2.20s 82% faster
Runner cache module 68 files / 16.29s 2 files / 1.24s 92% faster
Web email helper full package / 3.85s 10 files / 1.63s 58% faster
Documentation serial runtime gate no runtime tests runtime work removed

The complete gate moved in the opposite direction. The old root command executed 275 files and 1,825 cases in 34.70 seconds while omitting meaningful packages. The new unit surface executes 398 files and 2,226 cases in 52.44 seconds. The final ten-stage gate ran 2,440 automated cases plus schema verification, builds, local integration, Git smoke, and audit in 166.15 seconds. A later production run made the distinction memorable when a 14-second checkout still produced a 16-minute build.

We also learned not to confuse logical grouping with physical parallelism. Three CI groups became three jobs in the first rollout, but available capacity serialized them and repeated three cold installs. The gate regressed from 5:27 to 9:32. The groups now share one physical job and one locked install until the runner can execute them concurrently.

Lessons, without pretending there is one master lesson

I do not think this refactor produced one universal rule. It produced a set of constraints worth carrying forward:

  • Coverage needs a lifecycle. A new test should have an owner, execution tier, state boundary, and reason to continue existing.
  • Fast feedback and release proof are different products. Optimizing one command for both usually compromises both.
  • Ambiguity should widen selection. An affected-test router is useful only when uncertain dependencies fail closed.
  • Deletion needs replacement evidence. File counts are not a sufficient reason to remove a test; an equal or stronger boundary is.
  • Fixtures are architecture. Clocks, IDs, databases, providers, leases, background work, and teardown define whether a test is isolated.
  • Pre-production history is optional. Compatibility is valuable when users depend on it. Before then, it can freeze discarded designs into permanent obligations.
  • Measure calls, not just files. The fresh schema looked simpler while 602 sequential D1 calls made it slower.
  • CI topology must match real capacity. A matrix is not parallel merely because the YAML contains several jobs.

The remaining risk is the affected map itself. New packages, generated imports, subprocesses, and data-file contracts can make it stale. Router tests cover known boundaries, but they cannot encode a relationship nobody noticed. That is why affected selection remains an iteration tool and the complete gate remains release authority.

The final repository is not dramatically smaller: subsequent work left it at 408 test files and 61,641 lines. That is the point. The refactor did not declare tests to be the enemy. It stopped making every developer pay for every proof on every change.

The exact routing rules, replacement ledger, and measurement boundaries are recorded in the test architecture contract.