Ernie

Stop Writing CLAUDE.md Rules. Write Linting Rules Instead.

aiengineeringlintingeslintdeveloper-toolsclaude-code

Contents

The instinct everyone has first

An agent does something dumb in your codebase. It imports from the design system you migrated off of two quarters ago. It drops a console.log into a production path. It writes a 150-line function with a switch inside a try-catch inside a loop.

So you do the obvious thing. You open CLAUDE.md and you write it down. "Always use shadcn/ui. Never use console.log, use the logger. Keep functions small." You write it clearly. You write it in bold. Maybe you add a skill.

I did this for weeks. Everyone does. Even Vercel shipped a skill library — 40+ React performance rules, beautifully written, structured as SKILL.md files for agents to read. Genuinely good work.

And then the agent does the dumb thing again anyway.

Why prose loses at scale

A CLAUDE.md rule isn't really a rule. It's a suggestion you feed into a probabilistic system, hoping it draws the right card. Most of the time it does. You get beautiful, idiomatic code on the first try and you feel like a genius.

But "most of the time" is doing a lot of work in that sentence. When an agent is shipping more code in an afternoon than you'd review in a week, "most of the time" isn't a safety property. It's a countdown. Roll the dice enough and eventually the model drops an unindexed query into a hot path, and you find out when the database melts at 2 AM on a Friday.

Suggestion vs law

0 PRs shipped

Same PRs, same convention, two ways to enforce it. In the prose lane the rule lives in CLAUDE.md — the agent honors it most of the time. In the lint lane it's a CI check. Ship a few dozen and watch where the ~12% that slip actually land.

CLAUDE.mda suggestion

~12% chance to honor it — probabilistically

0reached prod

No PRs shipped yet.

Nothing slipped — yet. Keep shipping.

shipped cleanviolation in prod
Lint rulea law

0% chance to violate — it cannot merge

0reached prod

No PRs shipped yet.

0 reached prod. Nothing to block yet.

shipped cleanblocked at CI

“Most of the time” is not a safety property — it’s a countdown. The law never counts down.

A stick figure reads a CLAUDE.md rulebook aloud while a robot agent cheerfully ignores it — and the same robot stopped dead by a locked gate it can't pass. A suggestion versus a law.

That's the thing about a CLAUDE.md. It's firmly in pirate-code territory, more what you'd call guidelines than actual rules. It explains the why, which genuinely helps the agent get it right on the first try. What it can't do is make getting it wrong impossible. And once you're producing code faster than any human can read it, the gap between "usually right" and "can't be wrong" is the whole ballgame.

We already solved this. Decades ago.

This one gets me. We know this. We've known it for years.

Unit tests exist because humans forget edge cases. Linters exist because humans can't agree on where to put a curly brace and shouldn't have to. CI exists because "works on my machine" stopped being funny after the third production outage. The entire history of software tooling is us admitting, over and over, that good intentions don't survive contact with a real codebase. So we built machines that don't rely on them.

And then LLMs showed up and we collectively forgot all of it. "Just write a really good CLAUDE.md." "Just add more skills." "Just prompt it better." We took the one actor in the building that is definitionally probabilistic and decided the way to control it was… a nicely worded document.

CLAUDE.md explains the why and helps the agent get it right on the first try. A lint rule makes sure it can't get it wrong. Skills speed you up. Linters keep you honest. If you can only have one — take the linter.

The reframe that changed how I work: stop trying to teach the agent your conventions. Start making the wrong thing fail to merge. Move every rule you can from prose into an executable check. The agent doesn't have to remember something the CI will simply refuse.

The single highest-leverage rule: complexity caps

If you do one thing after reading this, do this one.

I don't mean style rules or import ordering. I mean hard caps on how big and how tangled a piece of code is allowed to get. It turns out ESLint has shipped these for years and almost nobody turns them on:

  • complexity — caps cyclomatic complexity
  • max-depth — limits how deeply you can nest
  • max-lines-per-function — forces decomposition
  • max-params — keeps interfaces narrow
  • max-statements — stops a function from doing twelve things at once
  • SonarJS cognitive-complexity — smarter about nested conditionals

A giant tangled monster-blob of code shoved through a tiny doorway labeled max-lines: 40 and emerging on the far side as neat little boxes named part1, part2, part3 — technically compliant, obviously gaming the cap. It complied. It did not improve.

I learned why these matter the fun way. Without them, an agent will cheerfully hand you a single 150-line function with six levels of nesting, three early returns, and a switch buried inside a try-catch inside a loop. It compiles. It passes tests. It even works. Then the next agent touches it, makes it worse, and now you have two of them.

Turn them on:

{
  "rules": {
    "complexity": ["error", 10],
    "max-depth": ["error", 3],
    "max-lines-per-function": ["error", 40],
    "max-params": ["error", 4],
    "max-statements": ["error", 15]
  }
}

Pair that with --max-warnings=0 so a warning is a failure, and watch what happens. The agent stops handing you 150-line monsters and starts extracting helpers and pulling concerns apart. Start strict, loosen only where it actively hurts.

But be honest about what this rule is, because it isn't the same kind of thing as forbidding console.log. That one is a law — the symbol is either in the file or it isn't, and there's no version of the code that breaks the rule and still reads fine. A complexity cap is a proxy: a number standing in for "is this readable." And the moment you make a proxy a hard gate, whatever's generating the code optimizes the proxy, not the goal. An agent staring down max-lines-per-function: 40 has two ways to comply — decompose the function honestly, or shatter it into handleThingPart2() and a couple of single-use helpers named to clear the counter. It will happily do the second. Its actual favorite move is the escape hatch:

// eslint-disable-next-line max-lines-per-function

So if you gate on complexity you have to shut that door too: ban or track disable comments (eslint-plugin-eslint-comments), or the agent routes around the whole thing and hands you a green checkmark nobody should trust. A rule everyone quietly learned to silence is worse than no rule at all.

Shut the escape hatch, treat the numbers as a starting point rather than scripture, and caps are still the highest-leverage rule I know — just don't mistake the proxy for the invariant. It's the one I most wish I'd turned on years ago, back when it was humans writing the 150-line functions.

Can this be a lint rule?

At some point a question started running on a loop in my head, every time something went wrong: can this be a lint rule?

Once that switch flips, you stop being a reviewer. You become the person who makes sure a given mistake can never happen again. Not "again this sprint." Ever.

The first one is the hardest. After that they snowball. A real one from my codebase: agents kept adding console.log to production code instead of the custom logger that routes to Datadog. Every. Single. Time. No amount of CLAUDE.md fixed it. A 10-line lint rule did: forbid console.log, suggest logger.error, done. I have not thought about it since.

An exhausted stick figure frantically whacking console.log moles that keep popping up all over a whack-a-mole board, until one small no console.log sign makes every mole stay down. One rule beat a thousand swings.

That's the shape of it. A recurring PR comment is a lint rule you haven't written yet. Every time you find yourself typing the same review note for the third time, that's the signal: stop reviewing, start enforcing.

But 50 rules sounds insane

People hear "we have 50 custom rules" and recoil. I did too.

A stick figure furious at a red X lint error opens a pull request titled 'change the rule' — which turns into two stick figures at a whiteboard finally having the architecture argument they never had. The rule you hate is the conversation you needed.

Yes, some of those rules will be wrong. Someone hits a bad one, gets annoyed, and opens a PR to change it. And right there, in that PR, you end up having the architectural conversation your team never quite had out loud. Should we actually allow this pattern? Why did we ban it? That PR is worth more than the rule that triggered it. The rule is just what forced the conversation to happen somewhere other than a Slack thread that scrolls away.

The failure mode is when they don't open the PR — they drop an eslint-disable, ship, and the rule dies in silence. That's why the number to watch is the disable count, not the rule count. If suppressions are climbing, either the rule is wrong or nobody's reviewing them, and both are worth finding out. A rule the whole team learned to route around isn't enforcement. It's decoration.

The old objection to adopting stricter rules was always "too many existing violations to fix." With an agent, that excuse is mostly dead. Point it at the violations and let it triage them in bulk; AI plus codemods turns the mechanical half of a migration from a quarter into an afternoon. The half that stays expensive is the human one — getting everyone to agree the rule is right, and eating the merge-conflict tax on every branch that was open when you flipped it. That was always the real cost. Automating the edits just stops it from hiding behind the busywork.

The stack doesn't matter

I keep saying ESLint because that's my world. The principle is not about ESLint. RuboCop, Ruff, clippy, golangci-lint. Pick your poison, same idea. Off-the-shelf linters handle syntax and the common bug classes for free; turning on plugins you'd been ignoring for years (SonarJS, unicorn, perfectionist) is often an afternoon that deletes entire categories of mistake.

The part that's yours is architecture, and even a lot of that ships off the shelf. "This layer can't import from that one" is no-restricted-imports or eslint-plugin-boundaries; "every API handler goes through the auth wrapper" is a no-restricted-syntax selector. Turn those on before you write a single line of rule code. Most of what feels bespoke isn't.

Custom rules are for the conventions that are genuinely yours, the ones that used to live only in the heads of your three most senior people. And authoring one is less scary than it sounds. A real rule — say, banning raw fetch in favor of your instrumented client — is basically a selector and a report:

export default {
  create(context) {
    return {
      "CallExpression[callee.name='fetch']"(node) {
        context.report({
          node,
          message: "Use apiClient, not raw fetch — it wires in tracing and auth.",
        })
      },
    }
  },
}

That's the whole thing. Write one and the fear evaporates; after that you start seeing rules everywhere, for every convention an agent has no way to absorb from vibes alone.

Where linting stops

I want to be honest about the ceiling, because linting isn't magic.

A lint rule can only catch what you've already seen. It encodes a mistake you've made and decided never to make again. The mistake you haven't hit yet is still out there, and no rule is watching for it. And linting is static: it reads the code, it can't run it. It'll happily confirm your rate-limiting middleware is defined in a file while having no idea it was never actually mounted. The code is right there. It's just not running.

Catching that is a different problem: the stuff you haven't seen, and the stuff that only surfaces when the app actually boots. That's not about instructing the agent better — it's about verifying what it produced, which is its own discipline with its own tools and its own headaches (not least that some layers of your stack are far cheaper to verify than others). Worth its own piece; not this one.

Because this piece was only ever about the input side: how you tell the agent what to do. The short version: stop writing it down and hoping. Make it a law.

Linters don't sleep, and they don't get tired. That's more than I can say for myself at 2 AM on a Friday.