Skip to content
Portrait of Euan Reid wearing aviator sunglasses
Euan
Reid
Technical leader · Software engineer

Tooling Matters

My practical philosophy on engineering tooling, the tools I favour, and a call to stop spending so much time on it.

Engineering teams spend too much time maintaining things that aren’t functionality. Linters, formatters, package-manager glue, hooks, CI wrappers, editor setup, and version management – all of it needs attention, and the interactions between them need more. Some of that work is necessary, but most of it isn’t.

A package script wraps a shell script because “consistency”. CI and editor plugins acquire their own copies of a tool. Someone fixes an editor warning by adding a duplicate configuration file. Eventually, a change passes locally and fails in CI, and an engineer spends the afternoon working out which version of which command actually decides whether the code is acceptable.

I treat tooling as a systems problem. A repository’s tools have components, boundaries, responsibilities, interfaces, failure modes, and an operational cost. We tend to choose them individually then leave their interactions to whoever next gets stuck. I’d rather design that system up front and ensure I can explain it.

Who Decides?

For starters, I take a leaf from RACI’s book: a tool is Responsible for enforcing a standard; the engineer is Accountable for the code they commit. If Ruff owns Python formatting, it decides what correctly formatted Python looks like in that repository. Another formatter doesn’t also get a vote. I’m still accountable for what I deliver, including changes a tool makes on my behalf. Passing a check doesn’t transfer that judgement to the executable.

That gives me one canonical owner per concern. Two formatters rewriting the same files are an obvious problem. Two linters enforcing the same rule, two task runners defining the same workflow, or an editor silently overriding the repository’s configuration are versions of the same mistake. I regard that overlap as a design smell. Every exception adds something the next engineer has to understand before they can trust the result.

Ownership also needs a scope. A tool may support ten languages while owning only one job in my repository. I can use Oxfmt for CSS formatting and Biome for CSS linting without enabling Biome’s formatter. A wrapper can call the owner; it shouldn’t carry an independent copy of the owner’s rules.

I’m certain I’ll replace several of these tools over time. Most of them replaced others, after all. When I do, I want the replacement to take over a defined responsibility without making everyone learn a new way to work in the repository. Put simply: which job does it do, and why should it do it?

The Repository Has an Interface

mise is my default approach for acquiring and versioning tools, setting up environments, and orchestrating tasks in a repository. Where practical, I start with mise use X which installs the tool and records the version requirement in the repository. I want setup to be something easy to do on a fresh checkout, with the same flow available to a developer and to CI.

I give Git hooks to prek, configured through prek.toml. Hooks invoke the appropriate checks using the repository’s chosen tools and configuration. Those checks must also work when run directly; committing shouldn’t be the only way to find out whether a change is ready.

In every repository, I want a small common set of commands:

mise run fmt
mise run fix
mise run check
mise run test
mise run build
mise run validate
mise run ci

fmt applies formatting and fix applies configured automated repairs. check reports static problems without changing files. test and build do what their names suggest, validate composes the local readiness checks, and ci is the entrypoint for an automated pipeline. Each repository should document what those last two involve. A desktop app and a website don’t use identical pipelines, or even need all seven commands.

The value is being able to move between repositories without relearning how to work. A Python task can call uv; a Rust task can call Cargo. If a web workspace already has a task graph, mise can delegate to it. Recreating that graph in mise would give me two places to maintain the same dependencies. Consistency matters most when moving across projects. The internals can vary more.

Humans, editors, CI, and coding agents all benefit from that interface. An agent should be able to discover how to validate a change without reconstructing the workflow from package scripts and pipeline YAML. The engineer reviewing its work should be able to run the same command.

I also commit explicit editor settings and useful extension recommendations. The selected formatter and its configuration should agree with the command line. A contributor’s personal editor setup shouldn’t be a hidden prerequisite for producing an acceptable commit.

Go Tall, Not Wide

Research by Ruder, Rutter, and others finds prose is most readable at 50-75 characters. Code isn’t prose, but the physical constraints of eyeballs scanning text don’t disappear. Many developers argue for 100 or 120 characters rather than the classic 80 of punch cards and terminals, saying wide monitors obviate that limit, but I’m a firm believer in the value of that brevity – closer to that optimal range, and far better for split editors and side-by-side diffs.

Putting separate arguments or entries on separate lines also gives a small change a small diff. That’s useful when reviewing an engineer’s work or an agent’s patch: I want to see what changed without scanning a rewritten line for the one altered value. Eighty columns is a target, and I leave the exact layout to the formatter. Manual line-wrapping disputes waste time.

Fast Enough to Matter

I prefer fast, portable tools. Portable because developers should be able to collaborate regardless of their OS. Fast because it changes how people work.

If a check finishes in a fraction of a second, all the context is still in my mind and I can keep working. If it takes a few minutes, I’m probably refilling my water or checking Slack, and some of that context is lost. If it takes longer, I’ll batch up changes, go do something else, find out about errors later, and have to reload all the working context to my brain.

Speed isn’t the most important thing, though. Correctness is. A faster linter that doesn’t handle newer syntax in a language or a test runner that doesn’t handle mocking correctly leaves work for someone else to do. When an ecosystem has authoritative tooling, that’s generally the safest bet.

The Tools I Use

For Python, I’m a firm believer in the Astral stack. uv handles packages and environments, Ruff handles formatting and linting, and ty handles type checking. I use pytest for tests.

For JavaScript and TypeScript, I favour Vite+ and its vp interface. It brings together tools including Oxfmt and Oxlint; its vp check combines formatting and linting with type checking when enabled. Integration is useful when it removes configuration and version coordination I’d otherwise have to maintain myself.

An integrated tool still needs boundaries. Vite+ can manage runtimes and hooks too. In my setup, prek keeps the hooks. For a runtime, I choose either mise or the ecosystem manager as the authority and have the other use that decision. Installing both and letting them independently select versions would defeat the point. The same applies to uv’s ability to manage Python versions.

Rust already has Cargo for builds and dependencies, with rustfmt for formatting and Clippy for linting. I use nextest for test execution and cargo-deny for dependency policy checks. The repository tasks can expose these tools without trying to hide Cargo’s model from the engineers using it.

The supporting files deserve owners too. My current choices are rumdl for Markdown formatting and linting, lychee for links, Tombi for TOML, and sqruff for SQL formatting and linting. For CSS, Oxfmt formats and Biome lints. These assignments also mean excluding those files from overlapping tools, even when a newly installed tool offers to check everything by default.

Buf is my choice for Protobuf. Like Vite+, it pulls together formatting and linting in a single tool, but it also handles some Protobuf-specific concerns: breaking-change detection and code generation.

Who Cares?

The point of good tooling is to let you not care. I don’t agree with every decision rustfmt makes about Rust or Oxfmt makes about TypeScript, but I don’t need to. Unless the choice they make is actively causing a problem, arguing with a formatter is a bad use of time. The responsibility is delegated to them the same way I delegate responsibility to engineers on my team – genuinely, not provisionally. Delegation only works if you actually let go.

By choosing good tools – fast, correct tools – and giving them clear responsibilities, we free up our time to focus on things that really matter. Engineering time is expensive – spend it wisely.