---
title: "Failing cheaply | Matt Fehrenbach"
description: "Ordering your checks by how expensive it is to ignore them."
canonical: https://m4t.tf/posts/20260921-1-failing-cheaply
---

[![](/favicon.png)](/)

[](/cv "CV")

menu

![Two satellite dishes on a desert ridge beneath a hazy orange sky.](https://cdn.m4t.tf/blog/sats2-540-crop.webp)

[Blog](/blog)

Failing cheaply
===============

2026-09-21·1299 words·7 min read

*   [dx](/tags/dx)
*   [tools](/tags/tools)
*   [git](/tags/git)
*   [programming](/tags/programming)

A two-minute test suite that tells you a variable is misspelled has cost you two minutes to learn something that was true in the first millisecond. The same mistake found in production costs a great deal more.

So I order my checks by that price, and there are four places they can run.

*   **Commit message**: free, and it happens while you are still thinking about the change.
*   **Pre-commit**: fast, and only on the files you staged.
*   **Pre-push**: seconds rather than milliseconds, but still before anyone else sees it.
*   **CI**: the expensive one, and the only place a check is guaranteed to have run at all.

commitlint
----------

The `commit-msg` hook is one line:

    commitlint --edit "$1"

It enforces [conventional commits](https://conventionalcommits.org/). A prefix like `fix:` or `feat:` is a claim about intent that a diff cannot make. The diff knows what changed. Only the author knows whether it was a bug fix or a new idea, and six months later that distinction is most of what makes a history readable.

You start from the shared conventional-commits preset and override the rules you disagree with. The default body length limit is 100 characters, for example, which is not much room to explain a trade-off. I raised mine to 200 after hitting the limit with my own commits.

The heavier option is commitizen, or semantic-release, both of which sit on commit conventions and use them to generate changelogs and cut releases. If you already have a release pipeline, that is worth having. I wanted the grammar enforced and nothing else, and commitlint is a rule set and a hook.

One engine for most of it
-------------------------

Most of these checks run through [Vite+](https://viteplus.dev), one CLI (`vp`) wrapping Vite, Vitest, Rolldown, Oxlint and Oxfmt. It is not tied to a framework, so the same setup works for React, Vue, Svelte or plain TypeScript.

*   `vp check` formats with Oxfmt, lints with Oxlint, and type checks.
*   `vp test` runs Vitest.
*   `vp staged` runs the relevant check against staged files on pre-commit.

Everything lives in one config file: import grouping, a ban on `./` and `../` imports in favour of path aliases, and class sorting if you write utility CSS. The alias rule is worth having on its own. It is a decision you would otherwise re-argue in every code review.

ESLint and Prettier were the baseline I left behind. Oxlint is [50 to 100 times faster than ESLint](https://github.com/oxc-project/bench-linter) on Oxc’s own benchmarks, and its type-aware engine, tsgolint, runs 12 to 18 times faster than `typescript-eslint` across large TypeScript codebases. Oxfmt is [up to 30 times faster than Prettier](https://voidzero.dev/posts/announcing-oxfmt-alpha) and stays compatible with its output. On a small project the seconds do not amount to much. The config surface does. One file replaces an ESLint config, a Prettier config, two ignore files, and the plugin wiring between them.

Biome is the alternative I would take seriously. It is one Rust tool doing both jobs, which solves the same problem. I stayed with Oxc because Vite+ pulls build, test, lint, format and the staged-file workflow under one CLI and one config, all sharing a parser. The cost of that bet is a younger plugin ecosystem. JavaScript plugins give back a chunk of the speed as well: one benchmark drops from roughly 16 times faster than ESLint to 6 times faster once plugins load. Even with a couple loaded, the margin is still wide.

fallow
------

On pre-push, before anything leaves your machine:

    vpx fallow

[fallow](https://docs.fallow.tools) is codebase intelligence: dead code, duplication, complexity and styling, returning a verdict instead of a wall of warnings. It finds the things that accumulate when code is written faster than it is read. An export nothing imports. A package still sitting in `package.json`. The same helper written twice in two files that never met.

A small config file silences false positives: path aliases that only resolve at build time, component internals generated by tooling, and dependencies used implicitly rather than imported. Every dead-code tool needs this. Budget for it.

![](https://cdn.m4t.tf/blog/bird-80.webp)

The tools it replaces are knip, jscpd and dependency-cruiser. That is three configs to write and three sets of output to read, and fallow covers the same ground in one Rust binary. On fallow’s benchmark set it analyses fastify in 64ms where knip takes 205ms, and preact in 74ms against knip’s 2.01s. Knip is faster on some Astro and TypeScript projects, and jscpd is still faster at raw duplication scanning. Breadth settled it, along with one thing the others lack. fallow ships an MCP server, so an agent can run the same analysis mid-session, not only at push time.

Two tools for specific stacks
-----------------------------

The next two tools assume the stack they were built for. The pattern still generalises when the tools do not.

Keeping the design system honest
--------------------------------

Components get edited by hand, copied between files, and adjusted one style at a time. That is how a design system erodes.

If you use Tailwind with shadcn components, [`@shadcn/lint`](https://ui.shadcn.com/docs/lint) runs inside the same Oxlint pass and enforces the vocabulary: no arbitrary values, no inline styles, no raw colours, no unknown classes, no restyling published components, and static class names that can be extracted. The directory holding copied primitives gets overrides, since those break some of the rules by design.

On a different design system, the equivalent is a plugin that knows your token vocabulary. Stylelint was the obvious other choice, but its rules are about CSS syntax. The question worth answering is whether a class belongs to this system or is a one-off that will read as inconsistency in a month.

react-doctor
------------

react-doctor runs on pre-push and as a GitHub Action on every pull request. It checks accessibility, performance, security and correctness, including the rules around the React Compiler. Its own description is blunt: _“Your agent writes bad React. This catches it.”_

On a React codebase it earns its place immediately. On anything else, skip it.

You can configure it to ignore the unused dependency rule for your manifest, because several packages get picked up by a framework rather than imported by name. Without that exception the report is noise on the first run.

The official `eslint-plugin-react-hooks` covers the rules of hooks and exhaustive dependencies, which are the two that matter most, and react-doctor includes them. I added it for everything else that is easy to get wrong in a component and hard to catch in review.

The rest of the hooks
---------------------

Not every hook is a linter. Git LFS handles large binary assets across checkout, commit, merge and push, which starts to matter when a repository holds video or high-resolution images. Whatever else your project needs a hook for belongs at the cheapest point it can run.

The same tools, for agents
--------------------------

The fallow MCP server is the part I keep coming back to. During a session, an agent can run the same audit and fix what it finds before writing a commit: an export it just orphaned, a helper it duplicated, a dependency it stopped using.

That is where the tooling earns its keep. The agent and I get the same signal, from the same tool, at the same moment.

CI as the backstop
------------------

Local hooks can be skipped, and a fresh clone has no memory of what you were supposed to run. So the same checks run again where nobody can forget them.

*   One job on every push to the main branch: run the checks and build.
*   One job on pull requests, reporting its findings back on the pull request itself.
*   One job on version tags: build, deploy, then smoke test what you deployed.

The smoke test is the cheapest check here and the only one that proves the thing you deployed is actually up.

Check

Runs at

Catches

Applies to

Replaced

commitlint

commit-msg

Commit message format

any repo

Reviewing history by hand

Oxfmt + Oxlint

pre-commit, CI

Formatting, lint, types, imports

any web app

Prettier + ESLint

fallow

pre-push, MCP

Dead code, duplication, complexity

JavaScript and TypeScript

Knip + jscpd

shadcn lint

pre-commit, CI

Design system drift

Tailwind + shadcn

Stylelint

react-doctor

pre-push, PRs

React accessibility, performance, security

React only

eslint-plugin-react-hooks

Smoke test

deploy

Downtime

any deployment

Hoping

What is left
------------

The pipeline rules out the boring mistakes while they are still cheap. Nothing more than that, really. The interesting ones are still yours to make.

![](https://cdn.m4t.tf/blog/pencil-and-rubber-128.webp)

Explore[Blog](/blog)[Photography](/photography)[Graphics](/graphics)[Music](/music)[Vignettes](/vignettes)

Site[About](/about)[Contact](/contact)[Privacy](/privacy)

Resources[Developers](/developers)

Back to top