DEV Community

Cover image for You Don't Need Another Agent. You Need a Linter.
Utkarsh Bansal
Utkarsh Bansal

Posted on

You Don't Need Another Agent. You Need a Linter.

In my last post I complained — a lot — about product managers and how they made my life hell with vibe code. PS: apologies, manager, if you're reading this — but it's true.

Now, I'm not here just to complain. There were a lot of learning opportunities too, like how to handle legacy / vibe code. Because at the end of the day, both are the same: no one knows how they work, but somehow they keep working. Touching them is like defusing a bomb — you never know how your change might cascade and break the core logic.

The good news is that vibe code is much simpler than legacy. AI, in all its glory, tries to write perfect-looking code — proper function names, comments, the works — not like legacy code where a single function runs 500 lines, with spaghetti names all over that make no sense and comments that are out of date.

And that makes it something I can actually handle. I still don't have a perfect, step-by-step playbook — but I've got pieces.

The first one. The cheapest and the oldest one. The one the industry solved decades ago and the whole "AI built my app in a day" crowd somehow forgot exists.

A linter.

Yes, you heard me right. A linter. ESLint.

Most people who've been in this industry already know it. It's the most boring, reliable tool in the box.

But in an era where the answer to every problem is "add another AI," it's worth saying out loud why the boring tool still wins.

What a linter actually is

If you vibe-coded your way into this world, or you're new to web dev in general and have never heard the word "lint", here's the honest version.

A linter is a set of rules you add to your repo. It reads your code without running it, checks it against those rules, and flags everything that's broken, sloppy, or about to bite you in production.

The detail people get wrong: it's not a grep for bad words.

A real linter parses your code into a syntax tree and actually reasons about its structure — what's imported, what's called, what's reachable, what types flow where.

That's why a rule like no-unused-vars can tell "you imported this and never used it" apart from "you used this without importing it."

It's also why eslint-plugin-unicorn can quietly rewrite array.indexOf(x) !== -1 into array.includes(x) (unicorn/prefer-includes), or flag a new Array(...) call that's going to bite you — without ever executing a single line.

Three properties matter:

  • It doesn't run your code. No side effects, no flaky network, no "works on my machine."
  • It's deterministic. Same code in, same result out, every single time. No temperature, no vibes.
  • It costs nothing. No tokens. No API bill. It runs in seconds on a laptop or a CI box.

In 2026, that last one is the whole argument.

Why this matters more than it used to

We have AI to write code. AI to review code. AI to verify code. Agents stacked on agents, each one quietly spending tokens to do something a free tool already did better.

And honestly? Most of those agents are re-solving problems that were solved long before "agent" meant what it means today.

You do not need a language model to notice that you left a debug log in production (no-console). Or imported a module you never used (unused-imports/no-unused-imports). Or wrote a function nobody calls (knip). Or forgot to await a promise (@typescript-eslint/no-floating-promises). Or made two files import each other in a circle (import/no-cycle).

A linter catches every one of those instantly, deterministically, for free — before any AI even wakes up.

So before you bolt another agent onto your pipeline, ask the boring question:

Does the free, battle-tested tool already do this?

Usually it does.

Use what already exists.

A linter is a Quality Gate

This is the real reason linters matter. Whether you're a big company or a freelancer just starting out.

A linter is a quality gate. A line in the sand that says:

This code does not get in unless it clears the bar.

Where I work, we wired this straight into the pull-request flow. A check a PR has to pass before it can merge.

Not a suggestion. Not "we'll clean it up later." A hard gate on the merge button.

The result was the kind of number that sounds made up until you live it: production issues and bad merges dropped by roughly 80%.

Magic? No.

Most of our incidents were the same boring handful — an unhandled promise that swallowed an error, a stray debug log left in, a circular import that only broke in prod. The gate caught every one of them at the door — we just stopped letting broken code through.

That's the whole trick.

A gate doesn't make anyone smarter. It just raises the floor.

A tip if you're at a big company: when you introduce the ESLint check, you'll hear a lot of pushback from devs — especially at a startup or midsize company. So be ready for a fight, and roll it out gradually: a warning gate first, then an error gate.

It doesn't stop at "missing semicolon"

Here's the common misconception people hit when they first add ESLint and see all those errors: "this thing is bad, it just nags me like my annoying brother." That's a huge underestimation.

Once you treat the linter as a real gate instead of a nag, you can teach it your specific mistakes. And it never forgets them.

Every painful bug in an AI-assisted codebase is also a pattern. And patterns are exactly what static analysis is good at.

A surprising amount of this is one config file away. Modern ESLint flat config plus a few community plugins covers most of it:

// eslint.config.js — a starter "quality gate", not a missing-semicolon nag
import unusedImports from "eslint-plugin-unused-imports";
import importPlugin from "eslint-plugin-import";
import unicorn from "eslint-plugin-unicorn";

export default [
  {
    plugins: { "unused-imports": unusedImports, import: importPlugin, unicorn },
    languageOptions: {
      // type-aware rules need the TS program, not just the syntax tree
      parserOptions: { projectService: true },
    },
    rules: {
      // ~100 perf + hidden-bug rules in one line; downgrade the churny ones
      ...unicorn.configs["flat/recommended"].rules,
      "unicorn/filename-case": "off",       // Vue files are PascalCase by convention
      "unicorn/prevent-abbreviations": "off", // trips on `ref`, `props`, store names
      // dead code the model left behind — auto-fixable
      "unused-imports/no-unused-imports": "error",
      "unused-imports/no-unused-vars": "warn",
      // the silent killer (see the circular-deps post) — crank maxDepth up
      "import/no-cycle": ["error", { maxDepth: 10, ignoreExternal: true }],
      // dropped awaits that swallow errors — needs the type info above
      "@typescript-eslint/no-floating-promises": "error",
      "@typescript-eslint/no-misused-promises": "error",
      // dynamic code execution: off by default in eslint:recommended, turn them on
      "no-eval": "error",
      "no-implied-eval": "error",
      "no-new-func": "error",
      "no-script-url": "error",
    },
  },
];
Enter fullscreen mode Exit fullscreen mode

That's the floor.

On top of it, every time something hurt us, we added a rule so it could only hurt us once. Three of them earned their place the hard way:

  • Circular dependenciesimport/no-cycle with maxDepth: 10 (the default of 1 only sees direct A↔B cycles), backed by dependency-cruiser for the whole-graph view. AI refactors love making two modules import each other until the build breaks only in prod. It's the single most common AI-induced bug I've seen — enough that it deserves a post of its own.
  • Dropped awaits@typescript-eslint/no-floating-promises and no-misused-promises. A missing await on a file or network call silently swallows the error, and you find out when a user does. Needs type-aware linting (parserOptions.projectService) — the one setup cost worth paying.
  • The free-wins packeslint-plugin-unicorn's flat/recommended: ~100 rules for one import. A mix of performance wins (prefer-includes, prefer-set-has), real hidden-bug catches (no-array-push-push, no-thenable), and some opinionated style. Turn off the churn (unicorn/filename-case, unicorn/prevent-abbreviations, unicorn/no-null) and keep the rest. Best caught-mistakes-to-config ratio you'll find.

The rest are quieter, but each one caught something once. Skim them:

  • Dead codeeslint-plugin-unused-imports sweeps the imports the model added for an approach it then abandoned (auto-fixed on --fix).
  • No evalno-eval, no-implied-eval, no-new-func, no-script-url. Off by default; zero legit uses, infinite ways for generated code to sneak one in.
  • XSS sinksvue/no-v-html / react/no-danger, plus eslint-plugin-no-unsanitized for raw innerHTML. Any HTML injection has to pass through a sanitizer, enforced at lint time.
  • Hardcoded stringseslint-plugin-i18next, so the day you add a language nothing's baked into the markup.
  • Test hygieneno-focused-tests as an error, so a stray it.only can't silently shrink the CI suite.
  • Leaked secretseslint-plugin-no-secrets flags high-entropy literals that look like committed tokens.
  • Design drift (custom) — a ~40-line AST script that fails the build when a raw hex/size/shadow shows up where a named token should be.
  • Layer boundarieseslint-plugin-boundaries to stop one layer importing another, plus a custom rule forcing risky mutations through a single function.

Each of these is a lesson we only had to learn once.

After that, the linter remembers it for us. Forever, for free, on every commit, for every contributor — human or AI.

The trick that keeps the gate from being annoying

Most people give up on linting for one reason. They turn on every rule at error on day one, drown in ten thousand warnings, and rage-quit.

Don't do that.

Run new rules warn-first. Let them report without blocking. Get the count to zero on your own schedule. Then ratchet that rule up to error so it can never regress.

Here's the reframe that matters: those warnings aren't a to-do list you owe the linter today. They're a map of where the mines are buried. You freeze the count, stop anyone from planting new ones, and clear the field at your own pace.

For the handful of legacy offenders you can't fix today, keep an explicit allowlist. And treat adding to that allowlist as the thing you're not allowed to do.

The list only ever shrinks.

That's how a gate stays strict without crying wolf — and how you tame inherited code without it blowing up in your face.

Gotchas

  • A linter only sees what's static. Dynamic imports, string-built code, and values resolved at runtime are invisible to it. Know the blind spots. A green lint is not "provably correct."
  • --fix is not free thinking. Auto-fix is great for unused imports and formatting. It is not great at deciding whether a flagged thing was a real mistake. Read the diff.
  • Gate on the trend, not the absolute count. Failing CI because the repo has any warnings is untenable in a vibe-coded codebase. Fail when the count grows past the baseline. That's actionable. "Be perfect" isn't.
  • A rule you suppress everywhere is a rule you deleted. If every other line has a disable comment, you didn't pass the gate — you removed it. Fix the code or kill the rule honestly.

The mild jab

Here's the irony I can already see coming. You've read this whole piece on why bolting AI onto already-solved problems is wasteful — and your very next move is to open a chat window and type "add ESLint and all the plugins to my repo." Or worse: "build me a code-review agent that flags unused imports and circular deps."

Stop.

Both are the wrong move. The first one drops you into a rabbit hole of 1000+ errors you'll be digging out of forever. Start with the base recommendation, then slowly work toward the rest.

As for the code-review agent — I'll pretend I didn't hear that. We just spent this whole article on why a linter beats an AI reviewer here. So why are you still reaching for it? Look, if you hate your tokens that much — if you're really itching to spend them on a problem a free, decades-old tool already solves deterministically — just send them my way. I'll put them to better use.

AI is genuinely good at the fuzzy stuff — turning an idea into reality, that dopamine hit of making something from nothing. Let it do that. Leave the heavy judgement of code quality to linter.

And let me be honest about the other side: a linter won't fix your architecture, won't catch a wrong business rule, won't tell you the feature was a bad idea. That part is still on you. What it will do is take the boring, mechanical, never-ending category of mistakes off your plate — so your brain is free for the part that actually needs one.

Takeaway

A linter is the cheapest reviewer on your team. Free, deterministic, tireless, and completely unimpressed by hype.

Make it a hard gate on merge, and you raise the floor for everyone at once.

Then teach it your own recurring mistakes — token drift, hardcoded strings, circular deps, unsafe HTML, dropped awaits — one custom rule per bug class, warn-first then ratcheted to error.

Add the linter first.

Then, if you still want an agent, at least it's reviewing code that already cleared the bar.

The cheapest reviewer you'll ever hire has been sitting in your repo the whole time.

Top comments (0)