Yordis Prieto Logo

Linters Are Back 🚦

I fell out of love with linters years ago. I never dropped them, I loved them less. ESLint[1] in JavaScript, Credo[2] in Elixir, golangci-lint[3] through my Go years at work.

Every setup shrank to the same core.

Anything auto-fixable stayed, because no human had to be in the loop. The correctness rules stayed too, the ones that catch real bugs and security issues.

What I dropped was the opinionated stuff: rules that flag code so a person can go fix it by hand. Above all, the rules that were pure personal opinion, nothing quantifiable, nothing qualifiable.

Strong opinions come with a cost, and a human paying that cost is too high a price.

Too high, at least, for the culture I believe in and try to build. High trust, high responsibility, high ownership.

You spend your credibility opinionating people with lints that sometimes make no sense. The rules add zero value, and a person still cleans up the mess.

So I kept the formatter, kept the bug catchers, and let the rest go.

What Changed

AI happened. An indeterministic machine now writes a large share of my code. When your generator is indeterministic, determinism stops being a nice-to-have. It becomes the whole game.

The economics flipped on both sides at once. Deterministic enforcement became critical, and the human cost disappeared.

The AI fixes its own violations. It does not get annoyed, does not bikeshed the rule in a PR comment, does not quit. A lint failure is deterministic feedback the machine reads and acts on. No paragraph of agent instructions comes close.

I am not the only one landing here either. Factory wrote about using linters to direct agents[4]. They treat lint rules as executable specifications the agent self-corrects against.

So now, every time the AI makes a mistake, or writes something I do not like, my first question is: can I encode this? Can I turn this disagreement into a rule that no indeterministic machine gets to vote on?

Two Different Intents

Encode it where, though?

The obvious place is the prompt: CLAUDE.md, agent instructions, skills.

That conflates two different intents.

All of them steer generation: context, taste, direction. A linter enforces an invariant. The wrong thing does not merge, no matter what the generator felt like today.

Prompts are useless the moment you use them for the second intent. Asking an indeterministic machine to police itself is a category error[10].

Express opinions in prompts. Enforce invariants in tools.

What I Have Been Encoding

Enforcement comes in layers, in every ecosystem. When an invariant fits in the type system, I put it there first. What the types rule out needs no review, no prompt, no lint. Custom lints take the rest.

This is where I fall in love with Rust the most: the type system lets me make the wrong thing impossible to express. The compiler enforces it on every build, for free. My lints catch whatever the compiler cannot see. They live in trogon_lints[5], built with Dylint[6], all compiled as deny. A few of them. Telemetry span names, metric names, and attributes must come from generated semconv constants. Never inline strings. No branching on error.to_string(), because display text is presentation, not a contract. No direct std::env access outside the one crate allowed to touch it.

In Elixir I lean on Credo[2] custom checks and architecture tests. Things like: the domain layer cannot depend on the persistence layer. Event Sourcing command handlers cannot call the wall clock, random, or UUID generators. The same goes for any other source of indeterminism I come across. Everything goes through injectable wrappers, so handlers stay deterministic and replayable. Background jobs must live in their own namespace. That way no module escapes the architecture checks.

In JavaScript I have been moving to Oxlint[7] lately. In one TypeScript codebase, reading process.env is an error. Only the few config modules allowed to touch it get a pass. That is the same invariant as my std::env lint in Rust, wearing a different ecosystem. I also use it to encode decisions I already wrote down as ADRs. Like my React project structure convention[8] with its no-imports-between-sibling-routes boundary. Or something as silly as how to import Lodash[9]. And many others. The decision was already made and written down. The linter is what makes it real.

The list keeps growing. Every new mistake is a candidate[11].

The Best Part

I do not write these rules by hand. The AI writes them. I review.

That closes the loop. The machine makes a mistake once. Then it writes the deterministic cage that prevents that mistake forever. I pay a one-time review instead of correcting the same thing every session. Each mistake stops being a transient annoyance and becomes permanent infrastructure.

Linters did not get better. The economics did.

References

  1. [1]
    eslint.org
  2. [2]
    github.com
  3. [3]
    golangci-lint.run
  4. [4]
  5. [5]
    github.com
  6. [6]
    github.com
  7. [7]
    oxc.rs
  8. [8]
  9. [9]
    straw-hat-team.github.io
  10. [10]
  11. [11]
    en.wikipedia.org

Talk to you later 🐊 alligator.

Stay in touch

Stay updated with my latest posts and project updates. Follow me on X to connect and discuss software development.